diff --git a/.env_example b/.env_example index 6468622..2779cf8 100644 --- a/.env_example +++ b/.env_example @@ -24,7 +24,5 @@ TASK_IMAGE_GENERATION_NIP89_DTAG3 = "asdasd" #Backend Specific Options for tasks that require them -#nova-server is a local backend supporting some AI modules and needs to be installed separately, -#if dvms supporting it should be used -OPENAI_API_KEY = "your-openai-api-key" -NOVA_SERVER = "127.0.0.1:37318" \ No newline at end of file +OPENAI_API_KEY = "" # Enter your OpenAI API Key to use DVMs with OpenAI services +NOVA_SERVER = "" # Enter the address of a nova-server instance, locally or on a machine in your network host:port \ No newline at end of file diff --git a/README.md b/README.md index 9a6ba7e..3910177 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,28 @@ -# NostrAI Data Vending Machine +# NostrAI: Nostr NIP90 Data Vending Machine Framework -This example DVM implementation in Python currently supports simple translations using Google translate, as well as extraction of text from links with pdf files. +This framework provides a way to easily build and/or run `Nostr NIP90 DVMs in Python`. -At a later stage, additional example tasks will be added, as well as the integration into a larger Machine Learning backend +This project is currently under development and additional tasks and features are added along the way. +This means the project is in alpha status, interfaces might still change/break. -Place .env file (based on .env_example) in main folder, install requirements.txt (python 3.10) run main.py. Optionally supports LNbits to create invoices instead of lnaddresses. +## To get started: +(Tested on Python 3.10) -Use vendata.io to create a nip89 announcement of your dvm and save the dtag in your .env config. +Create a new venv by running `"python -m venv venv"` + - Place .env file (based on .env_example) in main folder. + - Set your own private hex keys, create NIP89 dtags on vendata.io, + - Install requirements.txt + - Run python main.py. -A tutorial on how to add additional tasks, as well as the larger server backend will be added soon. +In `playground.py` some DVMs are already prepared. Feel free to play along with the existing ones. +You can also add new tasks by using the interface, just like the existing tasks in the `tasks` folder. -Known Issues: -- After refactoring DVMs work independent from each other for the most part. - - Some functions might work easier than they did before (need some refactoring) -- Bot currently not implemented -- Some basic functionality is still missing, e.g. handling various mediasources -- Interface might still change a lot and brick things. +A `bot` is running by default that lists and communicates with the `DVMs` added to it, +so your DVMs can be controled via any regular client as well. + +The Framework optionally supports `LNbits` to create invoices instead of using a `lightning address`. If LNBits is not used, +make sure your nostr accounts have a valid lightning address. + + +A tutorial on how to add additional tasks, as well as the larger server backend will be added at a later stage. diff --git a/bot.py b/bot.py index 493415f..55c5b7e 100644 --- a/bot.py +++ b/bot.py @@ -1,6 +1,7 @@ import json import time from datetime import timedelta +from threading import Thread from nostr_sdk import Keys, Client, Timestamp, Filter, nip04_decrypt, HandleNotification, EventBuilder, PublicKey, \ Event, Options @@ -10,7 +11,8 @@ from utils.backend_utils import get_amount_per_task from utils.database_utils import get_or_add_user, update_user_balance, create_sql_table, update_sql_table, User from utils.definitions import EventDefinitions from utils.nostr_utils import send_event, get_event_by_id -from utils.zap_utils import parse_amount_from_bolt11_invoice, check_for_zapplepay, decrypt_private_zap_message +from utils.zap_utils import parse_amount_from_bolt11_invoice, check_for_zapplepay, decrypt_private_zap_message, \ + parse_zap_event_tags class Bot: @@ -28,7 +30,8 @@ class Bot: pk = self.keys.public_key() - print("Nostr BOT public key: " + str(pk.to_bech32()) + " Hex: " + str(pk.to_hex()) + " Name: " + self.NAME + " Supported DVM tasks: " + + print("Nostr BOT public key: " + str(pk.to_bech32()) + " Hex: " + str(pk.to_hex()) + " Name: " + self.NAME + + " Supported DVM tasks: " + ', '.join(p.NAME + ":" + p.TASK for p in self.dvm_config.SUPPORTED_DVMS) + "\n") for relay in self.dvm_config.RELAY_LIST: @@ -36,8 +39,7 @@ class Bot: self.client.connect() zap_filter = Filter().pubkey(pk).kinds([EventDefinitions.KIND_ZAP]).since(Timestamp.now()) - dm_filter = Filter().pubkey(pk).kinds([EventDefinitions.KIND_DM]).since( - Timestamp.now()) + dm_filter = Filter().pubkey(pk).kinds([EventDefinitions.KIND_DM]).since(Timestamp.now()) self.client.subscribe([zap_filter, dm_filter]) @@ -50,7 +52,7 @@ class Bot: keys = self.keys def handle(self, relay_url, nostr_event): - if EventDefinitions.KIND_DM: + if nostr_event.kind() == EventDefinitions.KIND_DM: handle_dm(nostr_event) elif nostr_event.kind() == EventDefinitions.KIND_ZAP: handle_zap(nostr_event) @@ -63,24 +65,20 @@ class Bot: try: decrypted_text = nip04_decrypt(self.keys.secret_key(), nostr_event.pubkey(), nostr_event.content()) - user = get_or_add_user(db=self.dvm_config.DB, npub=sender, client=self.client) + user = get_or_add_user(db=self.dvm_config.DB, npub=sender, client=self.client, config=self.dvm_config) - # user = User - # user.npub = sender - # user.balance = 250 - # user.iswhitelisted = False - # user.isblacklisted = False - # user.name = "Test" - # user.nip05 = "Test@test" - # user.lud16 = "Test@test" # We do a selection of tasks now, maybe change this later, Idk. if decrypted_text[0].isdigit(): index = int(decrypted_text.split(' ')[0]) - 1 task = self.dvm_config.SUPPORTED_DVMS[index].TASK - print("["+ self.NAME + "] Request from " + str(user.name) + " (" + str(user.nip05) + ", Balance: "+ str(user.balance)+ " Sats) Task: " + str(task)) + print("[" + self.NAME + "] Request from " + str(user.name) + " (" + str(user.nip05) + ", Balance: " + + str(user.balance)+ " Sats) Task: " + str(task)) + + duration = 1 + required_amount = get_amount_per_task(self.dvm_config.SUPPORTED_DVMS[index].TASK, + self.dvm_config, duration) - required_amount = self.dvm_config.SUPPORTED_DVMS[index].COST if user.isblacklisted: # For some reason an admin might blacklist npubs, e.g. for abusing the service @@ -135,8 +133,8 @@ class Bot: print("payment-required") time.sleep(2.0) evt = EventBuilder.new_encrypted_direct_msg(self.keys, nostr_event.pubkey(), - "Balance required, please zap me with at least " + str( - int(required_amount - user.balance)) + "Balance required, please zap me with at least " + + str(int(required_amount - user.balance)) + " Sats, then try again.", nostr_event.id()).to_event(self.keys) time.sleep(2.0) @@ -149,7 +147,8 @@ class Bot: dvm_result = json.loads(decrypted_text) user_npub_hex = dvm_result["sender"] - user = get_or_add_user(db=self.dvm_config.DB, npub=user_npub_hex, client=self.client) + user = get_or_add_user(db=self.dvm_config.DB, npub=user_npub_hex, + client=self.client, config=self.dvm_config) print("[" + self.NAME + "] Received results, message to orignal sender " + user.name) reply_event = EventBuilder.new_encrypted_direct_msg(self.keys, PublicKey.from_hex(user.npub), @@ -159,7 +158,7 @@ class Bot: send_event(reply_event, client=self.client, dvm_config=dvm_config) else: - print("Message from " + user.name + ": " + decrypted_text) + print("[" + self.NAME + "] Message from " + user.name + ": " + decrypted_text) message = "DVMs that I support:\n\n" index = 1 for p in self.dvm_config.SUPPORTED_DVMS: @@ -173,61 +172,45 @@ class Bot: #nostr_event.id()).to_event(self.keys) time.sleep(3) send_event(evt, client=self.client, dvm_config=dvm_config) - except Exception as e: - print(e) + except Exception as e: + pass + # TODO we still receive (broken content) events after fetching the metadata, but we don't listen to them. + # probably in client.get_events_of in fetch_user_metadata + print("Error in bot " + str(e)) def handle_zap(zap_event): - zapped_event = None - invoice_amount = 0 - anon = False - sender = zap_event.pubkey() - print("Zap received") + print("[" + self.NAME + "] Zap received") try: - for tag in zap_event.tags(): - if tag.as_vec()[0] == 'bolt11': - invoice_amount = parse_amount_from_bolt11_invoice(tag.as_vec()[1]) - elif tag.as_vec()[0] == 'e': - zapped_event = get_event_by_id(tag.as_vec()[1], client=self.client, config=self.dvm_config) - elif tag.as_vec()[0] == 'description': - zap_request_event = Event.from_json(tag.as_vec()[1]) - sender = check_for_zapplepay(zap_request_event.pubkey().to_hex(), - zap_request_event.content()) - for z_tag in zap_request_event.tags(): - if z_tag.as_vec()[0] == 'anon': - if len(z_tag.as_vec()) > 1: - print("Private Zap received.") - decrypted_content = decrypt_private_zap_message(z_tag.as_vec()[1], - self.keys.secret_key(), - zap_request_event.pubkey()) - decrypted_private_event = Event.from_json(decrypted_content) - if decrypted_private_event.kind() == 9733: - sender = decrypted_private_event.pubkey().to_hex() - message = decrypted_private_event.content() - if message != "": - print("Zap Message: " + message) - else: - anon = True - print("Anonymous Zap received. Unlucky, I don't know from whom, and never will") - user = get_or_add_user(self.dvm_config.DB, sender, client=self.client) + invoice_amount, zapped_event, sender, anon = parse_zap_event_tags(zap_event, + self.keys, self.NAME, + self.client, self.dvm_config) + + user = get_or_add_user(self.dvm_config.DB, sender, client=self.client, config=self.dvm_config) if zapped_event is not None: if not anon: - print("Note Zap received for Bot balance: " + str(invoice_amount) + " Sats from " + str( + print("[" + self.NAME + "] Note Zap received for Bot balance: " + str(invoice_amount) + " Sats from " + str( user.name)) update_user_balance(self.dvm_config.DB, sender, invoice_amount, client=self.client, config=self.dvm_config) # a regular note elif not anon: - print("Profile Zap received for Bot balance: " + str(invoice_amount) + " Sats from " + str( + print("[" + self.NAME + "] Profile Zap received for Bot balance: " + str(invoice_amount) + " Sats from " + str( user.name)) update_user_balance(self.dvm_config.DB, sender, invoice_amount, client=self.client, config=self.dvm_config) except Exception as e: - print(f"Error during content decryption: {e}") + print("[" + self.NAME + "] Error during content decryption:" + str(e)) self.client.handle_notifications(NotificationHandler()) while True: time.sleep(1.0) + + def run(self): + bot = Bot + nostr_dvm_thread = Thread(target=bot, args=[self.dvm_config]) + nostr_dvm_thread.start() + diff --git a/dvm.py b/dvm.py index 9c0db4b..d352346 100644 --- a/dvm.py +++ b/dvm.py @@ -15,15 +15,13 @@ from utils.database_utils import update_sql_table, get_from_sql_table, \ from utils.nostr_utils import get_event_by_id, get_referenced_event_by_id, send_event from utils.output_utils import post_process_result, build_status_reaction from utils.zap_utils import check_bolt11_ln_bits_is_paid, parse_amount_from_bolt11_invoice, \ - check_for_zapplepay, decrypt_private_zap_message, create_bolt11_ln_bits + check_for_zapplepay, decrypt_private_zap_message, create_bolt11_ln_bits, parse_zap_event_tags use_logger = False if use_logger: init_logger(LogLevel.DEBUG) - - class DVM: dvm_config: DVMConfig admin_config: AdminConfig @@ -32,7 +30,7 @@ class DVM: job_list: list jobs_on_hold_list: list - def __init__(self, dvmconfig, adminconfig = None): + def __init__(self, dvmconfig, adminconfig=None): self.dvm_config = dvmconfig self.admin_config = adminconfig self.keys = Keys.from_sk_str(dvmconfig.PRIVATE_KEY) @@ -56,7 +54,8 @@ class DVM: self.client.connect() zap_filter = Filter().pubkey(pk).kinds([EventDefinitions.KIND_ZAP]).since(Timestamp.now()) - bot_dm_filter = Filter().pubkey(pk).kinds([EventDefinitions.KIND_DM]).authors(self.dvm_config.DM_ALLOWED).since(Timestamp.now()) + bot_dm_filter = Filter().pubkey(pk).kinds([EventDefinitions.KIND_DM]).authors(self.dvm_config.DM_ALLOWED).since( + Timestamp.now()) kinds = [EventDefinitions.KIND_NIP90_GENERIC] for dvm in self.dvm_config.SUPPORTED_DVMS: @@ -75,7 +74,8 @@ class DVM: def handle(self, relay_url, nostr_event): if EventDefinitions.KIND_NIP90_EXTRACT_TEXT <= nostr_event.kind() <= EventDefinitions.KIND_NIP90_GENERIC: - print("[" + self.dvm_config.NIP89.name + "] " + f"Received new NIP90 Job Request from {relay_url}: {nostr_event.as_json()}") + print( + "[" + self.dvm_config.NIP89.name + "] " + f"Received new NIP90 Job Request from {relay_url}: {nostr_event.as_json()}") handle_nip90_job_event(nostr_event) elif nostr_event.kind() == EventDefinitions.KIND_ZAP: handle_zap(nostr_event) @@ -86,7 +86,8 @@ class DVM: return def handle_nip90_job_event(nip90_event): - user = get_or_add_user(self.dvm_config.DB, nip90_event.pubkey().to_hex(), client=self.client) + user = get_or_add_user(self.dvm_config.DB, nip90_event.pubkey().to_hex(), client=self.client, + config=self.dvm_config) task_supported, task, duration = check_task_is_supported(nip90_event, client=self.client, get_duration=(not user.iswhitelisted), config=self.dvm_config) @@ -107,7 +108,8 @@ class DVM: task_is_free = True if user.iswhitelisted or task_is_free: - print("[" + self.dvm_config.NIP89.name + "] Free task or Whitelisted for task " + task + ". Starting processing..") + print( + "[" + self.dvm_config.NIP89.name + "] Free task or Whitelisted for task " + task + ". Starting processing..") send_job_status_reaction(nip90_event, "processing", True, 0, client=self.client, dvm_config=self.dvm_config) do_work(nip90_event, is_from_bot=False) @@ -118,7 +120,8 @@ class DVM: if tag.as_vec()[0] == 'bid': bid = int(tag.as_vec()[1]) - print("[" + self.dvm_config.NIP89.name + "] Payment required: New Nostr " + task + " Job event: " + nip90_event.as_json()) + print( + "[" + self.dvm_config.NIP89.name + "] Payment required: New Nostr " + task + " Job event: " + nip90_event.as_json()) if bid > 0: bid_offer = int(bid / 1000) if bid_offer >= amount: @@ -127,47 +130,21 @@ class DVM: client=self.client, dvm_config=self.dvm_config) else: # If there is no bid, just request server rate from user - print("[" + self.dvm_config.NIP89.name + "] Requesting payment for Event: " + nip90_event.id().to_hex()) + print( + "[" + self.dvm_config.NIP89.name + "] Requesting payment for Event: " + nip90_event.id().to_hex()) send_job_status_reaction(nip90_event, "payment-required", False, amount, client=self.client, dvm_config=self.dvm_config) else: print("Task not supported on this DVM, skipping..") def handle_zap(zap_event): - zapped_event = None - invoice_amount = 0 - anon = False - sender = zap_event.pubkey() print("Zap received") try: - for tag in zap_event.tags(): - if tag.as_vec()[0] == 'bolt11': - invoice_amount = parse_amount_from_bolt11_invoice(tag.as_vec()[1]) - elif tag.as_vec()[0] == 'e': - zapped_event = get_event_by_id(tag.as_vec()[1], client=self.client, config=self.dvm_config) - elif tag.as_vec()[0] == 'description': - zap_request_event = Event.from_json(tag.as_vec()[1]) - sender = check_for_zapplepay(zap_request_event.pubkey().to_hex(), - zap_request_event.content()) - for ztag in zap_request_event.tags(): - if ztag.as_vec()[0] == 'anon': - if len(ztag.as_vec()) > 1: - print("Private Zap received.") - decrypted_content = decrypt_private_zap_message(ztag.as_vec()[1], - self.keys.secret_key(), - zap_request_event.pubkey()) - decrypted_private_event = Event.from_json(decrypted_content) - if decrypted_private_event.kind() == 9733: - sender = decrypted_private_event.pubkey().to_hex() - message = decrypted_private_event.content() - if message != "": - print("Zap Message: " + message) - else: - anon = True - print("Anonymous Zap received. Unlucky, I don't know from whom, and never will") - user = get_or_add_user(self.dvm_config.DB, sender, client=self.client) - + invoice_amount, zapped_event, sender, anon = parse_zap_event_tags(zap_event, + self.keys, self.dvm_config.NIP89.name, + self.client, self.dvm_config) + user = get_or_add_user(db=self.dvm_config.DB, npub=sender, client=self.client, config=self.dvm_config) if zapped_event is not None: if zapped_event.kind() == EventDefinitions.KIND_FEEDBACK: # if a reaction by us got zapped @@ -219,13 +196,15 @@ class DVM: elif not anon: print("Note Zap received for Bot balance: " + str(invoice_amount) + " Sats from " + str( user.name)) - update_user_balance(self.dvm_config.DB, sender, invoice_amount, client=self.client, config=self.dvm_config) + update_user_balance(self.dvm_config.DB, sender, invoice_amount, client=self.client, + config=self.dvm_config) # a regular note elif not anon: print("Profile Zap received for Bot balance: " + str(invoice_amount) + " Sats from " + str( user.name)) - update_user_balance(self.dvm_config.DB, sender, invoice_amount, client=self.client, config=self.dvm_config) + update_user_balance(self.dvm_config.DB, sender, invoice_amount, client=self.client, + config=self.dvm_config) except Exception as e: print(f"Error during content decryption: {e}") @@ -264,7 +243,8 @@ class DVM: input = tag.as_vec()[1] input_type = tag.as_vec()[2] if input_type == "job": - evt = get_referenced_event_by_id(event_id=input, client=client, kinds=EventDefinitions.ANY_RESULT, + evt = get_referenced_event_by_id(event_id=input, client=client, + kinds=EventDefinitions.ANY_RESULT, dvm_config=dvmconfig) if evt is None: if append: @@ -287,7 +267,7 @@ class DVM: x.result = data x.is_processed = True if self.dvm_config.SHOW_RESULT_BEFORE_PAYMENT and not is_paid: - send_nostr_reply_event(data, original_event_str,) + send_nostr_reply_event(data, original_event_str, ) send_job_status_reaction(original_event, "success", amount, dvm_config=self.dvm_config) # or payment-required, or both? elif not self.dvm_config.SHOW_RESULT_BEFORE_PAYMENT and not is_paid: @@ -318,10 +298,11 @@ class DVM: } message = json.dumps(params) print(message) - response_event = EventBuilder.new_encrypted_direct_msg(self.keys, receiver_key, message, None).to_event(self.keys) + response_event = EventBuilder.new_encrypted_direct_msg(self.keys, receiver_key, message, + None).to_event(self.keys) send_event(response_event, client=self.client, dvm_config=self.dvm_config) else: - #Regular DVM reply + # Regular DVM reply send_nostr_reply_event(post_processed_content, original_event_str) except Exception as e: respond_to_error(str(e), original_event_str, False) @@ -347,7 +328,8 @@ class DVM: response_kind = original_event.kind() + 1000 reply_event = EventBuilder(response_kind, str(content), reply_tags).to_event(key) send_event(reply_event, client=self.client, dvm_config=self.dvm_config) - print("[" + self.dvm_config.NIP89.name + "] " + str(response_kind) + " Job Response event sent: " + reply_event.as_json()) + print("[" + self.dvm_config.NIP89.name + "] " + str( + response_kind) + " Job Response event sent: " + reply_event.as_json()) return reply_event.as_json() def respond_to_error(content: str, original_event_as_str: str, is_from_bot=False): @@ -366,7 +348,7 @@ class DVM: elif tag.as_vec()[0] == "i": task = tag.as_vec()[1] - user = get_or_add_user(self.dvm_config.DB, sender, self.client) + user = get_or_add_user(db=self.dvm_config.DB, npub=sender, client=self.client, config=self.dvm_config) if not user.iswhitelisted: amount = int(user.balance) + get_amount_per_task(task, self.dvm_config) update_sql_table(self.dvm_config.DB, sender, amount, user.iswhitelisted, user.isblacklisted, @@ -419,7 +401,7 @@ class DVM: status=status, result="", is_processed=False, bolt11=bolt11, payment_hash=payment_hash, expires=expires, from_bot=False)) - #print(str(self.job_list)) + # print(str(self.job_list)) if (status == "payment-required" or status == "payment-rejected" or ( status == "processing" and not is_paid) or (status == "success" and not is_paid)): @@ -435,7 +417,7 @@ class DVM: send_event(event, client=self.client, dvm_config=self.dvm_config) print("[" + self.dvm_config.NIP89.name + "]" + ": Sent Kind " + str( - EventDefinitions.KIND_FEEDBACK) + " Reaction: " + status + " " + event.as_json()) + EventDefinitions.KIND_FEEDBACK) + " Reaction: " + status + " " + event.as_json()) return event.as_json() def do_work(job_event, is_from_bot=False): @@ -495,4 +477,3 @@ class DVM: self.jobs_on_hold_list.remove(job) time.sleep(1.0) - diff --git a/interfaces/dvmtaskinterface.py b/interfaces/dvmtaskinterface.py index 0c4f0a3..10fa5a4 100644 --- a/interfaces/dvmtaskinterface.py +++ b/interfaces/dvmtaskinterface.py @@ -26,6 +26,19 @@ class DVMTaskInterface: nip89.content = nip89config.CONTENT return nip89 + def init(self, name, dvm_config, admin_config, nip89config): + self.NAME = name + self.PK = dvm_config.PRIVATE_KEY + if dvm_config.COST is not None: + self.COST = dvm_config.COST + + dvm_config.SUPPORTED_DVMS = [self] + dvm_config.DB = "db/" + self.NAME + ".db" + dvm_config.NIP89 = self.NIP89_announcement(nip89config) + self.dvm_config = dvm_config + self.admin_config = admin_config + + def run(self): nostr_dvm_thread = Thread(target=self.DVM, args=[self.dvm_config, self.admin_config]) nostr_dvm_thread.start() diff --git a/main.py b/main.py index 718b06d..dfc08ec 100644 --- a/main.py +++ b/main.py @@ -17,41 +17,49 @@ def run_nostr_dvm_with_local_config(): # We extract the Publickey from our bot, so the DVMs know who they should listen and react to. bot_publickey = Keys.from_sk_str(os.getenv("BOT_PRIVATE_KEY")).public_key() - # Spawn some DVMs in the playground and run them - # You can add arbitrary DVMs there and instantiate them here - - # Spawn DVM1 Kind 5000 Text Extractor from PDFs - pdfextractor = build_pdf_extractor("PDF Extractor", [bot_publickey]) - pdfextractor.run() - - # Spawn DVM2 Kind 5002 Text Translation - translator = build_translator("Translator", [bot_publickey]) - translator.run() - - # Spawn DVM3 Kind 5100 Image Generation This one uses a specific backend called nova-server. - # If you want to use it, see the instructions in backends/nova_server - unstable_artist = build_unstable_diffusion("Unstable Diffusion", [bot_publickey]) - unstable_artist.run() - - # Spawn DVM4, another Instance of text-to-image, as before but use a different privatekey, model and lora this time. - sketcher = build_sketcher("Sketcher", [bot_publickey]) - sketcher.run() - - dalle = build_dalle("Dall-E 3", [bot_publickey]) - dalle.run() - # We will run an optional bot that can communicate with the DVMs # Note this is very basic for now and still under development bot_config = DVMConfig() bot_config.PRIVATE_KEY = os.getenv("BOT_PRIVATE_KEY") bot_config.LNBITS_INVOICE_KEY = os.getenv("LNBITS_INVOICE_KEY") bot_config.LNBITS_URL = os.getenv("LNBITS_HOST") - # Finally we add some of the DVMs we created before to the Bot and start it. - bot_config.SUPPORTED_DVMS = [sketcher, unstable_artist, dalle, translator] - bot = Bot - nostr_dvm_thread = Thread(target=bot, args=[bot_config]) - nostr_dvm_thread.start() + # Spawn some DVMs in the playground and run them + # You can add arbitrary DVMs there and instantiate them here + + # Spawn DVM1 Kind 5000: A local Text Extractor from PDFs + pdfextractor = build_pdf_extractor("PDF Extractor", [bot_publickey]) + # If we don't add it to the bot, the bot will not provide access to the DVM + pdfextractor.run() + + # Spawn DVM2 Kind 5002 Local Text Translation, calling the free Google API. + translator = build_translator("Translator", [bot_publickey]) + bot_config.SUPPORTED_DVMS.append(translator) # We add translator to the bot + translator.run() + + # Spawn DVM3 Kind 5100 Image Generation This one uses a specific backend called nova-server. + # If you want to use it, see the instructions in backends/nova_server + if os.getenv("NOVA_SERVER") is not None and os.getenv("NOVA_SERVER") != "": + unstable_artist = build_unstable_diffusion("Unstable Diffusion", [bot_publickey]) + bot_config.SUPPORTED_DVMS.append(unstable_artist) # We add unstable Diffusion to the bot + unstable_artist.run() + + # Spawn DVM4, another Instance of text-to-image, as before but use a different privatekey, model and lora this time. + if os.getenv("NOVA_SERVER") is not None and os.getenv("NOVA_SERVER") != "": + sketcher = build_sketcher("Sketcher", [bot_publickey]) + bot_config.SUPPORTED_DVMS.append(sketcher) # We also add Sketcher to the bot + sketcher.run() + + # Spawn DVM5, this one requires an OPENAI API Key and balance with OpenAI, you will move the task to them and pay + # per call. Make sure you have enough balance and the DVM's cost is set higher than what you pay yourself, except, you know, + # you're being generous. + if os.getenv("OPENAI_API_KEY") is not None and os.getenv("OPENAI_API_KEY") != "": + dalle = build_dalle("Dall-E 3", [bot_publickey]) + bot_config.SUPPORTED_DVMS.append(dalle) + dalle.run() + + bot = Bot(bot_config) + bot.run() # Keep the main function alive for libraries like openai try: diff --git a/playground.py b/playground.py index d3978c1..9666a9f 100644 --- a/playground.py +++ b/playground.py @@ -8,6 +8,7 @@ from tasks.translation import Translation from utils.admin_utils import AdminConfig from utils.dvmconfig import DVMConfig from utils.nip89_utils import NIP89Config + """ This File is a playground to create DVMs. It shows some examples of DVMs that make use of the modules in the tasks folder These DVMs should be considered examples and will be extended in the future. env variables are used to not commit keys, @@ -32,6 +33,8 @@ task, for example an address or an API key. # their NIP89 announcement admin_config = AdminConfig() admin_config.REBROADCAST_NIP89 = False +# Set rebroadcast to true once you have set your NIP89 descriptions and d tags. You only need to rebroadcast once you +# want to update your NIP89 descriptions def build_pdf_extractor(name, dm_allowed_keys): @@ -67,11 +70,11 @@ def build_translator(name, dm_allowed_keys): "language": { "required": False, "values": ["en", "az", "be", "bg", "bn", "bs", "ca", "ceb", "co", "cs", "cy", "da", "de", "el", "eo", "es", - "et", "eu", "fa", "fi", "fr", "fy", "ga", "gd", "gl","gu", "ha", "haw", "hi", "hmn", "hr", "ht", - "hu", "hy", "id", "ig", "is", "it", "he", "ja", "jv", "ka", "kk","km", "kn", "ko", "ku", "ky", + "et", "eu", "fa", "fi", "fr", "fy", "ga", "gd", "gl", "gu", "ha", "haw", "hi", "hmn", "hr", "ht", + "hu", "hy", "id", "ig", "is", "it", "he", "ja", "jv", "ka", "kk", "km", "kn", "ko", "ku", "ky", "la", "lb", "lo", "lt", "lv", "mg", "mi", "mk", "ml", "mn", "mr", "ms", "mt", "my", "ne", "nl", "no", "ny", "or", "pa", "pl", "ps", "pt", "ro", "ru", "sd", "si", "sk", "sl", "sm", "sn", "so", - "sq", "sr", "st", "su", "sv", "sw", "ta", "te","tg", "th", "tl", "tr", "ug", "uk", "ur", "uz", + "sq", "sr", "st", "su", "sv", "sw", "ta", "te", "tg", "th", "tl", "tr", "ug", "uk", "ur", "uz", "vi", "xh", "yi", "yo", "zh", "zu"] } } @@ -158,12 +161,15 @@ def build_sketcher(name, dm_allowed_keys): return ImageGenerationSDXL(name=name, dvm_config=dvm_config, nip89config=nip89config, admin_config=admin_config, options=options) + def build_dalle(name, dm_allowed_keys): dvm_config = DVMConfig() dvm_config.PRIVATE_KEY = os.getenv("NOSTR_PRIVATE_KEY3") dvm_config.LNBITS_INVOICE_KEY = os.getenv("LNBITS_INVOICE_KEY") dvm_config.LNBITS_URL = os.getenv("LNBITS_HOST") dvm_config.DM_ALLOWED = dm_allowed_keys + profit_in_sats = 10 + dvm_config.COST = int(((4.0 / (get_price_per_sat("USD") * 100)) + profit_in_sats)) nip90params = { "size": { @@ -186,4 +192,24 @@ def build_dalle(name, dm_allowed_keys): nip89config.CONTENT = json.dumps(nip89info) # We add an optional AdminConfig for this one, and tell the dvm to rebroadcast its NIP89 return ImageGenerationDALLE(name=name, dvm_config=dvm_config, nip89config=nip89config, - admin_config=admin_config) \ No newline at end of file + admin_config=admin_config) + + +# Little Gimmick: +# For Dalle where we have to pay 4cent per image, we fetch current sat price in fiat +# and update cost at each start +def get_price_per_sat(currency): + import requests + + url = "https://api.coinstats.app/public/v1/coins" + params = {"skip": 0, "limit": 1, "currency": currency} + try: + response = requests.get(url, params=params) + response_json = response.json() + + bitcoin_price = response_json["coins"][0]["price"] + price_currency_per_sat = bitcoin_price / 100000000.0 + except: + price_currency_per_sat = 0.0004 + + return price_currency_per_sat diff --git a/tasks/imagegeneration_openai_dalle.py b/tasks/imagegeneration_openai_dalle.py index 8d81aee..1b1784a 100644 --- a/tasks/imagegeneration_openai_dalle.py +++ b/tasks/imagegeneration_openai_dalle.py @@ -23,23 +23,14 @@ Params: -model # models: juggernaut, dynavision, colossusProject, newrea class ImageGenerationDALLE(DVMTaskInterface): - NAME: str = "" KIND: int = EventDefinitions.KIND_NIP90_GENERATE_IMAGE TASK: str = "text-to-image" COST: int = 120 - PK: str - DVM = DVM def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, admin_config: AdminConfig = None, options=None): - self.NAME = name - self.PK = dvm_config.PRIVATE_KEY - dvm_config.SUPPORTED_DVMS = [self] - dvm_config.DB = "db/" + self.NAME + ".db" - dvm_config.NIP89 = self.NIP89_announcement(nip89config) - self.dvm_config = dvm_config - self.admin_config = admin_config + self.init(name, dvm_config, admin_config, nip89config) self.options = options def is_input_supported(self, input_type, input_content): diff --git a/tasks/imagegeneration_sdxl.py b/tasks/imagegeneration_sdxl.py index 3ad712e..df07576 100644 --- a/tasks/imagegeneration_sdxl.py +++ b/tasks/imagegeneration_sdxl.py @@ -20,22 +20,12 @@ Params: -model # models: juggernaut, dynavision, colossusProject, newrea class ImageGenerationSDXL(DVMTaskInterface): - NAME: str = "" KIND: int = EventDefinitions.KIND_NIP90_GENERATE_IMAGE TASK: str = "text-to-image" COST: int = 50 - PK: str - DVM = DVM def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, admin_config: AdminConfig = None, options=None): - self.NAME = name - self.PK = dvm_config.PRIVATE_KEY - - dvm_config.SUPPORTED_DVMS = [self] - dvm_config.DB = "db/" + self.NAME + ".db" - dvm_config.NIP89 = self.NIP89_announcement(nip89config) - self.dvm_config = dvm_config - self.admin_config = admin_config + self.init(name, dvm_config, admin_config, nip89config) self.options = options def is_input_supported(self, input_type, input_content): diff --git a/tasks/textextractionpdf.py b/tasks/textextractionpdf.py index 59b6c52..ba85899 100644 --- a/tasks/textextractionpdf.py +++ b/tasks/textextractionpdf.py @@ -21,22 +21,13 @@ Params: None class TextExtractionPDF(DVMTaskInterface): - NAME: str = "" KIND: int = EventDefinitions.KIND_NIP90_EXTRACT_TEXT TASK: str = "pdf-to-text" COST: int = 0 - PK: str - DVM = DVM - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, admin_config: AdminConfig = None): - self.NAME = name - self.PK = dvm_config.PRIVATE_KEY - - dvm_config.SUPPORTED_DVMS = [self] - dvm_config.DB = "db/" + self.NAME + ".db" - dvm_config.NIP89 = self.NIP89_announcement(nip89config) - self.dvm_config = dvm_config - self.admin_config = admin_config + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, admin_config: AdminConfig = None, options=None): + self.init(name, dvm_config, admin_config, nip89config) + self.options = options def is_input_supported(self, input_type, input_content): diff --git a/tasks/translation.py b/tasks/translation.py index 08d5cc1..d852265 100644 --- a/tasks/translation.py +++ b/tasks/translation.py @@ -19,22 +19,14 @@ Params: -language The target language class Translation(DVMTaskInterface): - NAME: str = "" KIND: int = EventDefinitions.KIND_NIP90_TRANSLATE_TEXT TASK: str = "translation" COST: int = 0 - PK: str - DVM = DVM - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, admin_config: AdminConfig = None): - self.NAME = name - self.PK = dvm_config.PRIVATE_KEY - - dvm_config.SUPPORTED_DVMS = [self] - dvm_config.DB = "db/" + self.NAME + ".db" - dvm_config.NIP89 = self.NIP89_announcement(nip89config) - self.dvm_config = dvm_config - self.admin_config = admin_config + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, admin_config: AdminConfig = None, + options=None): + self.init(name, dvm_config, admin_config, nip89config) + self.options = options def is_input_supported(self, input_type, input_content): if input_type != "event" and input_type != "job" and input_type != "text": diff --git a/utils/admin_utils.py b/utils/admin_utils.py index bde1110..96a1f54 100644 --- a/utils/admin_utils.py +++ b/utils/admin_utils.py @@ -50,7 +50,7 @@ def admin_make_database_updates(adminconfig: AdminConfig = None, dvmconfig: DVMC if whitelistuser: - user = get_or_add_user(db, publickey, client=client) + user = get_or_add_user(db, publickey, client=client, config=dvmconfig) update_sql_table(db, user.npub, user.balance, True, False, user.nip05, user.lud16, user.name, user.lastactive) user = get_from_sql_table(db, publickey) print(str(user.name) + " is whitelisted: " + str(user.iswhitelisted)) diff --git a/utils/database_utils.py b/utils/database_utils.py index 4c13f04..a022645 100644 --- a/utils/database_utils.py +++ b/utils/database_utils.py @@ -8,9 +8,7 @@ from dataclasses import dataclass from datetime import timedelta from logging import Filter -from nostr_sdk import Timestamp, Keys, PublicKey, EventBuilder, Metadata, Filter, Options, Client - -from utils.definitions import NEW_USER_BALANCE +from nostr_sdk import Timestamp, Keys, PublicKey, EventBuilder, Filter, Client, Options from utils.nostr_utils import send_event @@ -66,7 +64,7 @@ def add_to_sql_table(db, npub, sats, iswhitelisted, isblacklisted, nip05, lud16, con.commit() con.close() except Error as e: - print(e) + print("Error when Adding to DB: " + str(e)) def update_sql_table(db, npub, balance, iswhitelisted, isblacklisted, nip05, lud16, name, lastactive): @@ -87,7 +85,7 @@ def update_sql_table(db, npub, balance, iswhitelisted, isblacklisted, nip05, lud con.commit() con.close() except Error as e: - print(e) + print("Error Updating DB: " + str(e)) def get_from_sql_table(db, npub): @@ -113,7 +111,7 @@ def get_from_sql_table(db, npub): return user except Error as e: - print(e) + print("Error Getting from DB: " + str(e)) def delete_from_sql_table(db, npub): @@ -155,72 +153,70 @@ def list_db(db): print(e) -def update_user_balance(db, sender, sats, client, config): - user = get_from_sql_table(db, sender) +def update_user_balance(db, npub, additional_sats, client, config): + user = get_from_sql_table(db, npub) if user is None: - add_to_sql_table(db, sender, (int(sats) + NEW_USER_BALANCE), False, False, - "", "", "", Timestamp.now().as_secs()) - print("NEW USER: " + sender + " Zap amount: " + str(sats) + " Sats.") + name, nip05, lud16 = fetch_user_metadata(npub, client) + add_to_sql_table(db, npub, (int(additional_sats) + config.NEW_USER_BALANCE), False, False, + nip05, lud16, name, Timestamp.now().as_secs()) + print("Adding User: " + npub + " (" + npub + ")") else: - user = get_from_sql_table(db, sender) - print(str(sats)) - - if user.nip05 is None: - user.nip05 = "" - if user.lud16 is None: - user.lud16 = "" - if user.name is None: - user.name = "" - - new_balance = int(user.balance) + int(sats) - update_sql_table(db, sender, new_balance, user.iswhitelisted, user.isblacklisted, user.nip05, user.lud16, + user = get_from_sql_table(db, npub) + new_balance = int(user.balance) + int(additional_sats) + update_sql_table(db, npub, new_balance, user.iswhitelisted, user.isblacklisted, user.nip05, user.lud16, user.name, Timestamp.now().as_secs()) - print("UPDATE USER BALANCE: " + str(user.name) + " Zap amount: " + str(sats) + " Sats.") + print("Updated user balance for: " + str(user.name) + + " Zap amount: " + str(additional_sats) + " Sats. New balance: " + str(new_balance) +" Sats") if config is not None: keys = Keys.from_sk_str(config.PRIVATE_KEY) time.sleep(1.0) - message = ("Added " + str(sats) + " Sats to balance. New balance is " + str(new_balance) + " Sats. ") + message = ("Added " + str(additional_sats) + " Sats to balance. New balance is " + str(new_balance) + " Sats.") - evt = EventBuilder.new_encrypted_direct_msg(keys, PublicKey.from_hex(sender), message, + evt = EventBuilder.new_encrypted_direct_msg(keys, PublicKey.from_hex(npub), message, None).to_event(keys) send_event(evt, client=client, dvm_config=config) -def get_or_add_user(db, npub, client): +def get_or_add_user(db, npub, client, config): user = get_from_sql_table(db, npub) if user is None: - name, nip05, lud16 = fetch_user_metadata(npub, client) - print("Adding User: " + npub + " (" + npub + ")") - add_to_sql_table(db, npub, NEW_USER_BALANCE, False, False, nip05, - lud16, name, Timestamp.now().as_secs()) - user = get_from_sql_table(db, npub) - return user + try: + name, nip05, lud16 = fetch_user_metadata(npub, client) + print("Adding User: " + npub + " (" + npub + ")") + add_to_sql_table(db, npub, config.NEW_USER_BALANCE, False, False, nip05, + lud16, name, Timestamp.now().as_secs()) + user = get_from_sql_table(db, npub) + return user + except Exception as e: + print("Error Adding User to DB: " + str(e)) return user +class DvmConfig: + pass + + def fetch_user_metadata(npub, client): name = "" nip05 = "" lud16 = "" - - # Get metadata pk = PublicKey.from_hex(npub) print(f"\nGetting profile metadata for {pk.to_bech32()}...") - filter = Filter().kind(0).author(pk).limit(1) - events = client.get_events_of([filter], timedelta(seconds=3)) + profile_filter = Filter().kind(0).author(pk).limit(1) + events = client.get_events_of([profile_filter], timedelta(seconds=5)) + #TODO, it seems our client is still subscribed after that + if len(events) > 0: latest_entry = events[0] - newest = 0 + latest_time = 0 for entry in events: - if entry.created_at().as_secs() > newest: - newest = entry.created_at().as_secs() + if entry.created_at().as_secs() > latest_time: + latest_time = entry.created_at().as_secs() latest_entry = entry - - print(latest_entry.content()) profile = json.loads(latest_entry.content()) if profile.get("name"): name = profile['name'] @@ -228,32 +224,4 @@ def fetch_user_metadata(npub, client): nip05 = profile['nip05'] if profile.get("lud16"): lud16 = profile['lud16'] - - return name, nip05, lud16 - - -def fetch_user_metadata2(sender, client) -> (str, str, str): - name = "" - nip05 = "" - lud16 = "" - try: - pk = PublicKey.from_hex(sender) - print(f"\nGetting profile metadata for {pk.to_bech32()}...") - profile_filter = Filter().kind(0).author(pk).limit(1) - events = client.get_events_of([profile_filter], timedelta(seconds=1)) - if len(events) > 0: - ev = events[0] - metadata = Metadata.from_json(ev.content()) - name = metadata.get_display_name() - if str(name) == "" or name is None: - name = metadata.get_name() - nip05 = metadata.get_nip05() - lud16 = metadata.get_lud16() - else: - print("Profile not found") - return name, nip05, lud16 - - except: - print("Couldn't get meta information") - return name, nip05, lud16 diff --git a/utils/definitions.py b/utils/definitions.py index ceed65e..28a173e 100644 --- a/utils/definitions.py +++ b/utils/definitions.py @@ -2,10 +2,6 @@ import os from dataclasses import dataclass from nostr_sdk import Event - -NEW_USER_BALANCE: int = 250 # Free credits for new users - - class EventDefinitions: KIND_DM: int = 4 KIND_ZAP: int = 9735 diff --git a/utils/dvmconfig.py b/utils/dvmconfig.py index 679597e..a42d479 100644 --- a/utils/dvmconfig.py +++ b/utils/dvmconfig.py @@ -6,6 +6,7 @@ from utils.nip89_utils import NIP89Announcement class DVMConfig: SUPPORTED_DVMS= [] PRIVATE_KEY: str = os.getenv("NOSTR_PRIVATE_KEY") + COST: int = None RELAY_LIST = ["wss://relay.damus.io", "wss://nostr-pub.wellorder.net", "wss://nos.lol", "wss://nostr.wine", "wss://relay.nostfiles.dev", "wss://nostr.mom", "wss://nostr.oxtr.dev", "wss://relay.nostr.bg", @@ -15,11 +16,11 @@ class DVMConfig: LNBITS_INVOICE_KEY = '' LNBITS_URL = 'https://lnbits.com' DB: str + NEW_USER_BALANCE: int = 250 # Free credits for new users NIP89: NIP89Announcement DM_ALLOWED = [] - REQUIRES_NIP05: bool = False - SHOW_RESULT_BEFORE_PAYMENT: bool = True # if this is true show results even when not paid right after autoprocess + SHOW_RESULT_BEFORE_PAYMENT: bool = False # if this is true show results even when not paid right after autoprocess diff --git a/utils/zap_utils.py b/utils/zap_utils.py index adc74d7..2e6c372 100644 --- a/utils/zap_utils.py +++ b/utils/zap_utils.py @@ -4,8 +4,9 @@ import json import requests from Crypto.Cipher import AES from bech32 import bech32_decode, convertbits -from nostr_sdk import nostr_sdk, PublicKey, SecretKey +from nostr_sdk import nostr_sdk, PublicKey, SecretKey, Event from utils.dvmconfig import DVMConfig +from utils.nostr_utils import get_event_by_id def parse_amount_from_bolt11_invoice(bolt11_invoice: str) -> int: @@ -35,6 +36,42 @@ def parse_amount_from_bolt11_invoice(bolt11_invoice: str) -> int: return int(number) +def parse_zap_event_tags(zap_event, keys, name, client, config): + zapped_event = None + invoice_amount = 0 + anon = False + sender = zap_event.pubkey() + + for tag in zap_event.tags(): + if tag.as_vec()[0] == 'bolt11': + invoice_amount = parse_amount_from_bolt11_invoice(tag.as_vec()[1]) + elif tag.as_vec()[0] == 'e': + zapped_event = get_event_by_id(tag.as_vec()[1], client=client, config=config) + elif tag.as_vec()[0] == 'description': + zap_request_event = Event.from_json(tag.as_vec()[1]) + sender = check_for_zapplepay(zap_request_event.pubkey().to_hex(), + zap_request_event.content()) + for z_tag in zap_request_event.tags(): + if z_tag.as_vec()[0] == 'anon': + if len(z_tag.as_vec()) > 1: + print("[" + name + "] Private Zap received.") + decrypted_content = decrypt_private_zap_message(z_tag.as_vec()[1], + keys.secret_key(), + zap_request_event.pubkey()) + decrypted_private_event = Event.from_json(decrypted_content) + if decrypted_private_event.kind() == 9733: + sender = decrypted_private_event.pubkey().to_hex() + message = decrypted_private_event.content() + if message != "": + print("Zap Message: " + message) + else: + anon = True + print( + "[" + name + "] Anonymous Zap received. Unlucky, I don't know from whom, and never will") + + return invoice_amount, zapped_event, sender, anon + + def create_bolt11_ln_bits(sats: int, config: DVMConfig) -> (str, str): url = config.LNBITS_URL + "/api/v1/payments" data = {'out': False, 'amount': sats, 'memo': "Nostr-DVM " + config.NIP89.name}