diff --git a/.env_example b/.env_example index e6ee61b..ff9802a 100644 --- a/.env_example +++ b/.env_example @@ -17,7 +17,7 @@ LIBRE_TRANSLATE_API_KEY = "" # API Key, if required (You can host your own inst REPLICATE_API_TOKEN = "" #API Key to run models on replicate.com HUGGINGFACE_EMAIL = "" HUGGINGFACE_PASSWORD = "" - +COINSTATSOPENAPI_KEY = "" # We will automatically create dtags and private keys based on the identifier variable in main. # If your DVM already has a dtag and private key you can replace it here before publishing the DTAG to not create a new one. diff --git a/.gitignore b/.gitignore index 2e6c75b..0bcf1a8 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,6 @@ ui/noogle/node_modules ui/noogle/.idea/ ui/noogle/package-lock.json search.py +identifier.sqlite +.idea/dataSources.xml +todo.txt diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index d8ecaba..ccc5e7e 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -68,5 +68,50 @@ + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/db/nostr_profiles.db + $ProjectFileDir$ + + + file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.43.0/org/xerial/sqlite-jdbc/3.43.0.0/sqlite-jdbc-3.43.0.0.jar + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:identifier.sqlite + $ProjectFileDir$ + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:identifier.sqlite + $ProjectFileDir$ + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:identifier.sqlite + $ProjectFileDir$ + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/db/subscriptions + $ProjectFileDir$ + + + file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.43.0/org/xerial/sqlite-jdbc/3.43.0.0/sqlite-jdbc-3.43.0.0.jar + + + \ No newline at end of file diff --git a/README.md b/README.md index 0e4e3dc..e07d4b6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# NostrAI: Nostr NIP90 Data Vending Machine Framework +# NostrDVM: Nostr NIP90 Data Vending Machine Framework This framework provides a way to easily build and/or run `Nostr NIP90 DVMs in Python`. diff --git a/examples/ollama_dvm/test_client.py b/examples/ollama_dvm/test_client.py index b16aa35..331745d 100644 --- a/examples/ollama_dvm/test_client.py +++ b/examples/ollama_dvm/test_client.py @@ -4,7 +4,7 @@ from pathlib import Path from threading import Thread import dotenv -from nostr_sdk import Keys, Client, ClientSigner, Tag, EventBuilder, Filter, HandleNotification, Timestamp, nip04_decrypt +from nostr_sdk import Keys, Client, NostrSigner, Tag, EventBuilder, Filter, HandleNotification, Timestamp, nip04_decrypt from nostr_dvm.utils.dvmconfig import DVMConfig from nostr_dvm.utils.nostr_utils import send_event, check_and_set_private_key @@ -12,7 +12,7 @@ from nostr_dvm.utils.definitions import EventDefinitions def nostr_client_test_llm(prompt): - keys = Keys.from_sk_str(check_and_set_private_key("test_client")) + keys = Keys.parse(check_and_set_private_key("test_client")) iTag = Tag.parse(["i", prompt, "text"]) relaysTag = Tag.parse(['relays', "wss://relay.damus.io", "wss://blastr.f7z.xyz", "wss://relayable.org", @@ -24,7 +24,7 @@ def nostr_client_test_llm(prompt): relay_list = ["wss://relay.damus.io", "wss://blastr.f7z.xyz", "wss://relayable.org", "wss://nostr-pub.wellorder.net"] - signer = ClientSigner.keys(keys) + signer = NostrSigner.keys(keys) client = Client(signer) for relay in relay_list: @@ -35,7 +35,7 @@ def nostr_client_test_llm(prompt): return event.as_json() def nostr_client(): - keys = Keys.from_sk_str(check_and_set_private_key("test_client")) + keys = Keys.parse(check_and_set_private_key("test_client")) sk = keys.secret_key() pk = keys.public_key() print(f"Nostr Test Client public key: {pk.to_bech32()}, Hex: {pk.to_hex()} ") diff --git a/examples/tts_dvm/test_client.py b/examples/tts_dvm/test_client.py index bd211f3..a12a3a5 100644 --- a/examples/tts_dvm/test_client.py +++ b/examples/tts_dvm/test_client.py @@ -5,7 +5,7 @@ from threading import Thread import dotenv from nostr_sdk import Keys, Client, Tag, EventBuilder, Filter, HandleNotification, Timestamp, nip04_decrypt, \ - ClientSigner + NostrSigner from nostr_dvm.utils.dvmconfig import DVMConfig from nostr_dvm.utils.nostr_utils import send_event, check_and_set_private_key @@ -13,7 +13,7 @@ from nostr_dvm.utils.definitions import EventDefinitions def nostr_client_test_tts(prompt): - keys = Keys.from_sk_str(check_and_set_private_key("test_client")) + keys = Keys.parse(check_and_set_private_key("test_client")) iTag = Tag.parse(["i", prompt, "text"]) paramTag1 = Tag.parse(["param", "language", "en"]) @@ -30,7 +30,7 @@ def nostr_client_test_tts(prompt): "wss://nostr-pub.wellorder.net"] - signer = ClientSigner.keys(keys) + signer = NostrSigner.keys(keys) client = Client(signer) for relay in relay_list: client.add_relay(relay) @@ -40,11 +40,11 @@ def nostr_client_test_tts(prompt): return event.as_json() def nostr_client(): - keys = Keys.from_sk_str(check_and_set_private_key("test_client")) + keys = Keys.parse(check_and_set_private_key("test_client")) sk = keys.secret_key() pk = keys.public_key() print(f"Nostr Test Client public key: {pk.to_bech32()}, Hex: {pk.to_hex()} ") - signer = ClientSigner.keys(keys) + signer = NostrSigner.keys(keys) client = Client(signer) dvmconfig = DVMConfig() diff --git a/examples/unleashed_dvm/.env_example b/examples/unleashed_dvm/.env_example new file mode 100644 index 0000000..7e177bc --- /dev/null +++ b/examples/unleashed_dvm/.env_example @@ -0,0 +1,18 @@ +#Create an account with a lnbits instance of your choice, add the admin key and id here. This account will be used to create a new lnbits wallet for each dvm/bot +LNBITS_ADMIN_KEY = "" +LNBITS_ADMIN_ID = "" +LNBITS_HOST = "https://lnbits.com" #Use your own/a trusted instance ideally. +# In order to create a zappable lightning address, host nostdress on your domain or use this preinstalled domain. +# We will use the api to create and manage zapable lightning addresses +NOSTDRESS_DOMAIN = "nostrdvm.com" + + +UNLEASHED_API_KEY = "" + + +# We will automatically create dtags and private keys based on the identifier variable in main. +# If your DVM already has a dtag and private key you can replace it here before publishing the DTAG to not create a new one. +# The name and NIP90 info of the DVM can be changed but the identifier must stay the same in order to not create a different dtag. + +# We will also create new wallets on the given lnbits instance for each dvm. If you want to use an existing wallet, you can replace the parameters here as well. +# Make sure you backup this file to keep access to your wallets \ No newline at end of file diff --git a/examples/unleashed_dvm/README.md b/examples/unleashed_dvm/README.md new file mode 100644 index 0000000..d40bb1b --- /dev/null +++ b/examples/unleashed_dvm/README.md @@ -0,0 +1,26 @@ +# NostrAI: Nostr NIP90 Data Vending Machine Framework Example + +Projects in this folder contain ready-to-use DVMs. To tun the DVM following the next steps: + +## To get started: +- Install Python (tested on 3.10/3.11) + + +Create a new venv in this directory by opening the terminal here, or navigate to this directory and type: `"python -m venv venv"` + - Place .env file (based on .env_example) in this folder. + - Recommended but optional: + - Create a `LNbits` account on an accessible instance of your choice, enter one account's id and admin key (this account will create other accounts for the dvms) Open the .env file and enter this info to `LNBITS_ADMIN_KEY`, `LNBITS_ADMIN_ID`, `LNBITS_HOST`. + - If you are running an own instance of `Nostdress` enter `NOSTDRESS_DOMAIN` or use the default one. + - Activate the venv with + - MacOS/Linux: source ./venv/bin/activate + - Windows: .\venv\Scripts\activate + - Type: `pip install nostr-dvm` + - Run `python3 main.py` (or python main.py) + - The framework will then automatically create keys, nip89 tags and zapable NIP57 `lightning addresses` for your dvms in this file. + - Check the .env file if these values look correct. + - Check the `main.py` file. You can update the image/description/name of your DVM before announcing it. + - You can then in main.py set `admin_config.REBROADCAST_NIP89` and + `admin_config.UPDATE_PROFILE` to `True` to announce the NIP89 info and update the npubs profile automatically. + - After this was successful you can set these back to False until the next time you want to update the NIP89 or profile. + +You are now running your own DVM. diff --git a/examples/unleashed_dvm/main.py b/examples/unleashed_dvm/main.py new file mode 100644 index 0000000..d32c250 --- /dev/null +++ b/examples/unleashed_dvm/main.py @@ -0,0 +1,55 @@ +import json +from pathlib import Path +import dotenv + +from nostr_dvm.tasks.textgeneration_unleashed_chat import TextGenerationUnleashedChat +from nostr_dvm.utils.admin_utils import AdminConfig +from nostr_dvm.utils.dvmconfig import build_default_config +from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag + + +def main(): + identifier = "unleashed" + name = "Unleashed Chat" + dvm_config = build_default_config(identifier) + dvm_config.SEND_FEEDBACK_EVENTS = False + dvm_config.USE_OWN_VENV = False + dvm_config.FIX_COST = 0 + admin_config = AdminConfig() + admin_config.LUD16 = dvm_config.LN_ADDRESS + admin_config.REBROADCAST_NIP89 = False + + + nip89info = { + "name": name, + "image": "https://unleashed.chat/_app/immutable/assets/hero.pehsu4x_.jpeg", + "about": "I generate Text with Unleashed.chat", + "encryptionSupported": True, + "cashuAccepted": True, + "nip90Params": {} + } + + nip89config = NIP89Config() + nip89config.DTAG = check_and_set_d_tag(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) + nip89config.CONTENT = json.dumps(nip89info) + + + + unleashed = TextGenerationUnleashedChat(name=name, dvm_config=dvm_config, nip89config=nip89config, admin_config=admin_config) + unleashed.run() + + + + +if __name__ == '__main__': + env_path = Path('.env') + if not env_path.is_file(): + with open('.env', 'w') as f: + print("Writing new .env file") + f.write('') + if env_path.is_file(): + print(f'loading environment from {env_path.resolve()}') + dotenv.load_dotenv(env_path, verbose=True, override=True) + else: + raise FileNotFoundError(f'.env file not found at {env_path} ') + main() diff --git a/examples/unleashed_dvm/test_client.py b/examples/unleashed_dvm/test_client.py new file mode 100644 index 0000000..9c964b3 --- /dev/null +++ b/examples/unleashed_dvm/test_client.py @@ -0,0 +1,103 @@ +import json +import time +from datetime import timedelta +from pathlib import Path +from threading import Thread + +import dotenv +from nostr_sdk import Keys, Client, Tag, EventBuilder, Filter, HandleNotification, Timestamp, nip04_decrypt, \ + NostrSigner, Options + +from nostr_dvm.utils.dvmconfig import DVMConfig +from nostr_dvm.utils.nostr_utils import send_event, check_and_set_private_key +from nostr_dvm.utils.definitions import EventDefinitions + + +def nostr_client_test(prompt): + keys = Keys.parse(check_and_set_private_key("test_client")) + + iTag = Tag.parse(["i", prompt, "text"]) + + + relaysTag = Tag.parse(['relays', "wss://relay.damus.io", "wss://blastr.f7z.xyz", "wss://relayable.org", + "wss://nostr-pub.wellorder.net"]) + alttag = Tag.parse(["alt", "This is a NIP90 DVM AI task to generate TTS"]) + event = EventBuilder(EventDefinitions.KIND_NIP90_GENERATE_TEXT, str("Answer to prompt"), + [iTag, relaysTag, alttag]).to_event(keys) + + relay_list = ["wss://relay.damus.io", "wss://blastr.f7z.xyz", "wss://relayable.org", + "wss://nostr-pub.wellorder.net"] + + opts = (Options().wait_for_send(False).send_timeout(timedelta(seconds=5))) + signer = NostrSigner.keys(keys) + client = Client.with_opts(signer,opts) + for relay in relay_list: + client.add_relay(relay) + client.connect() + config = DVMConfig + send_event(event, client=client, dvm_config=config) + return event.as_json() + +def nostr_client(): + keys = Keys.parse(check_and_set_private_key("test_client")) + sk = keys.secret_key() + pk = keys.public_key() + print(f"Nostr Test Client public key: {pk.to_bech32()}, Hex: {pk.to_hex()} ") + signer = NostrSigner.keys(keys) + client = Client(signer) + + dvmconfig = DVMConfig() + for relay in dvmconfig.RELAY_LIST: + client.add_relay(relay) + client.connect() + + dm_zap_filter = Filter().pubkey(pk).kinds([EventDefinitions.KIND_DM, + EventDefinitions.KIND_ZAP]).since( + Timestamp.now()) # events to us specific + dvm_filter = (Filter().kinds([EventDefinitions.KIND_NIP90_RESULT_GENERATE_TEXT, + EventDefinitions.KIND_FEEDBACK]).since(Timestamp.now())) # public events + client.subscribe([dm_zap_filter, dvm_filter]) + + + #nostr_client_test("What has Pablo been up to?") + nostr_client_test("What is Gigi talking about recently?") + print("Sending Job Request") + + + class NotificationHandler(HandleNotification): + def handle(self, relay_url, event): + print(f"Received new event from {relay_url}: {event.as_json()}") + if event.kind() == 7000: + print("[Nostr Client]: " + event.as_json()) + elif 6000 < event.kind() < 6999: + print("[Nostr Client " + event.author().to_bech32() + "]: " + event.as_json()) + print("[Nostr Client " + event.author().to_bech32() + "]: " + event.content()) + + + elif event.kind() == 4: + dec_text = nip04_decrypt(sk, event.author(), event.content()) + print("[Nostr Client]: " + f"Received new msg: {dec_text}") + + elif event.kind() == 9735: + print("[Nostr Client]: " + f"Received new zap:") + print(event.as_json()) + + def handle_msg(self, relay_url, msg): + return + + client.handle_notifications(NotificationHandler()) + while True: + time.sleep(1) + + +if __name__ == '__main__': + + env_path = Path('.env') + if env_path.is_file(): + print(f'loading environment from {env_path.resolve()}') + dotenv.load_dotenv(env_path, verbose=True, override=True) + else: + raise FileNotFoundError(f'.env file not found at {env_path} ') + + nostr_dvm_thread = Thread(target=nostr_client()) + nostr_dvm_thread.start() diff --git a/main.py b/main.py index b23b124..91f796b 100644 --- a/main.py +++ b/main.py @@ -5,9 +5,10 @@ from sys import platform from nostr_dvm.bot import Bot from nostr_dvm.tasks import videogeneration_replicate_svd, imagegeneration_replicate_sdxl, textgeneration_llmlite, \ - trending_notes_nostrband, discovery_inactive_follows, translation_google, textextraction_pdf, \ + discovery_trending_notes_nostrband, discovery_inactive_follows, translation_google, textextraction_pdf, \ translation_libretranslate, textextraction_google, convert_media, imagegeneration_openai_dalle, texttospeech, \ - imagegeneration_sd21_mlx, advanced_search, textgeneration_huggingchat, summarization_huggingchat + imagegeneration_sd21_mlx, advanced_search, textgeneration_huggingchat, summarization_huggingchat, \ + discovery_nonfollowers, search_users from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.backend_utils import keep_alive from nostr_dvm.utils.definitions import EventDefinitions @@ -24,13 +25,12 @@ def playground(): # Note this is very basic for now and still under development bot_config = DVMConfig() bot_config.PRIVATE_KEY = check_and_set_private_key("bot") - npub = Keys.from_sk_str(bot_config.PRIVATE_KEY).public_key().to_bech32() + npub = Keys.parse(bot_config.PRIVATE_KEY).public_key().to_bech32() invoice_key, admin_key, wallet_id, user_id, lnaddress = check_and_set_ln_bits_keys("bot", npub) bot_config.LNBITS_INVOICE_KEY = invoice_key bot_config.LNBITS_ADMIN_KEY = admin_key # The dvm might pay failed jobs back bot_config.LNBITS_URL = os.getenv("LNBITS_HOST") - # Generate an optional Admin Config, in this case, whenever we give our DVMs this config, they will (re)broadcast # their NIP89 announcement # You can create individual admins configs and hand them over when initializing the dvm, @@ -39,13 +39,15 @@ def playground(): admin_config = AdminConfig() admin_config.REBROADCAST_NIP89 = False admin_config.LUD16 = lnaddress + + + # 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 # Update the DVMs (not the bot) profile. For example after you updated the NIP89 or the lnaddress, you can automatically update profiles here. admin_config.UPDATE_PROFILE = False - # Spawn some DVMs in the playground and run them # You can add arbitrary DVMs there and instantiate them here @@ -62,11 +64,11 @@ def playground(): # Spawn DVM3 Kind 5002 Local Text TranslationLibre, calling the free LibreTranslateApi, as an alternative. # This will only run and appear on the bot if an endpoint is set in the .env if os.getenv("LIBRE_TRANSLATE_ENDPOINT") is not None and os.getenv("LIBRE_TRANSLATE_ENDPOINT") != "": - libre_translator = translation_libretranslate.build_example("Libre Translator", "libre_translator", admin_config) + libre_translator = translation_libretranslate.build_example("Libre Translator", "libre_translator", + admin_config) bot_config.SUPPORTED_DVMS.append(libre_translator) # We add translator to the bot libre_translator.run() - # Spawn DVM4, 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. @@ -76,17 +78,18 @@ def playground(): dalle.run() if os.getenv("REPLICATE_API_TOKEN") is not None and os.getenv("REPLICATE_API_TOKEN") != "": - sdxlreplicate = imagegeneration_replicate_sdxl.build_example("Stable Diffusion XL", "replicate_sdxl", admin_config) + sdxlreplicate = imagegeneration_replicate_sdxl.build_example("Stable Diffusion XL", "replicate_sdxl", + admin_config) bot_config.SUPPORTED_DVMS.append(sdxlreplicate) sdxlreplicate.run() if os.getenv("REPLICATE_API_TOKEN") is not None and os.getenv("REPLICATE_API_TOKEN") != "": - svdreplicate = videogeneration_replicate_svd.build_example("Stable Video Diffusion", "replicate_svd", admin_config) + svdreplicate = videogeneration_replicate_svd.build_example("Stable Video Diffusion", "replicate_svd", + admin_config) bot_config.SUPPORTED_DVMS.append(svdreplicate) svdreplicate.run() - - #Let's define a function so we can add external DVMs to our bot, we will instanciate it afterwards + # Let's define a function so we can add external DVMs to our bot, we will instanciate it afterwards # Spawn DVM5.. oh wait, actually we don't spawn a new DVM, we use the dvmtaskinterface to define an external dvm by providing some info about it, such as # their pubkey, a name, task, kind etc. (unencrypted) @@ -97,7 +100,6 @@ def playground(): bot_config.SUPPORTED_DVMS.append(tasktiger_external) # Don't run it, it's on someone else's machine, and we simply make the bot aware of it. - # DVM: 6 Another external dvm for recommendations: ymhm_external = build_external_dvm(pubkey="6b37d5dc88c1cbd32d75b713f6d4c2f7766276f51c9337af9d32c8d715cc1b93", task="content-discovery", @@ -107,28 +109,30 @@ def playground(): # If we get back a list of people or events, we can post-process it to make it readable in social clients bot_config.SUPPORTED_DVMS.append(ymhm_external) - # Spawn DVM 7 Find inactive followers googleextractor = textextraction_google.build_example("Extractor", "speech_recognition", admin_config) bot_config.SUPPORTED_DVMS.append(googleextractor) googleextractor.run() - # Spawn DVM 8 A Media Grabber/Converter media_bringer = convert_media.build_example("Media Bringer", "media_converter", admin_config) bot_config.SUPPORTED_DVMS.append(media_bringer) media_bringer.run() - - # Spawn DVM9 Find inactive followers discover_inactive = discovery_inactive_follows.build_example("Bygones", "discovery_inactive_follows", admin_config) bot_config.SUPPORTED_DVMS.append(discover_inactive) discover_inactive.run() - trending = trending_notes_nostrband.build_example("Trending Notes on nostr.band", "trending_notes_nostrband", admin_config) + discover_nonfollowers = discovery_nonfollowers.build_example("Not Refollowing", "discovery_non_followers", + admin_config) + bot_config.SUPPORTED_DVMS.append(discover_nonfollowers) + discover_nonfollowers.run() + + trending = discovery_trending_notes_nostrband.build_example("Trending Notes on nostr.band", "trending_notes_nostrband", + admin_config) bot_config.SUPPORTED_DVMS.append(trending) trending.run() @@ -144,8 +148,12 @@ def playground(): bot_config.SUPPORTED_DVMS.append(search) search.run() + profile_search = search_users.build_example("Profile Searcher", "profile_search", admin_config) + bot_config.SUPPORTED_DVMS.append(profile_search) + profile_search.run() - inactive = discovery_inactive_follows.build_example("Inactive People you follow", "discovery_inactive_follows", admin_config) + inactive = discovery_inactive_follows.build_example("Inactive People you follow", "discovery_inactive_follows", + admin_config) bot_config.SUPPORTED_DVMS.append(inactive) inactive.run() @@ -156,16 +164,15 @@ def playground(): mlx.run() if os.getenv("HUGGINGFACE_EMAIL") is not None and os.getenv("HUGGINGFACE_EMAIL") != "": - hugginchat = textgeneration_huggingchat.build_example("Huggingchat", "huggingchat",admin_config) + hugginchat = textgeneration_huggingchat.build_example("Huggingchat", "huggingchat", admin_config) bot_config.SUPPORTED_DVMS.append(hugginchat) hugginchat.run() - hugginchatsum = summarization_huggingchat.build_example("Huggingchat Summarizer", "huggingchatsum", admin_config) + hugginchatsum = summarization_huggingchat.build_example("Huggingchat Summarizer", "huggingchatsum", + admin_config) bot_config.SUPPORTED_DVMS.append(hugginchatsum) hugginchatsum.run() - - # Run the bot Bot(bot_config) # Keep the main function alive for libraries that require it, like openai diff --git a/nostr_dvm/bot.py b/nostr_dvm/bot.py index cde4ece..89dcc0f 100644 --- a/nostr_dvm/bot.py +++ b/nostr_dvm/bot.py @@ -5,7 +5,8 @@ import time from datetime import timedelta from nostr_sdk import (Keys, Client, Timestamp, Filter, nip04_decrypt, HandleNotification, EventBuilder, PublicKey, - Options, Tag, Event, nip04_encrypt, ClientSigner, EventId, Nip19Event) + Options, Tag, Event, nip04_encrypt, NostrSigner, EventId, Nip19Event, Kind, KindEnum, + UnsignedEvent, UnwrappedGift) from nostr_dvm.utils.admin_utils import admin_make_database_updates from nostr_dvm.utils.database_utils import get_or_add_user, update_user_balance, create_sql_table, update_sql_table @@ -30,12 +31,12 @@ class Bot: nip89config.NAME = self.NAME self.dvm_config.NIP89 = nip89config self.admin_config = admin_config - self.keys = Keys.from_sk_str(dvm_config.PRIVATE_KEY) + self.keys = Keys.parse(dvm_config.PRIVATE_KEY) wait_for_send = True skip_disconnected_relays = True opts = (Options().wait_for_send(wait_for_send).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT)) .skip_disconnected_relays(skip_disconnected_relays)) - signer = ClientSigner.keys(self.keys) + signer = NostrSigner.keys(self.keys) self.client = Client.with_opts(signer, opts) pk = self.keys.public_key() @@ -52,145 +53,210 @@ class Bot: zap_filter = Filter().pubkey(pk).kinds([EventDefinitions.KIND_ZAP]).since(Timestamp.now()) dm_filter = Filter().pubkey(pk).kinds([EventDefinitions.KIND_DM]).since(Timestamp.now()) + nip59_filter = Filter().pubkey(pk).kind(Kind.from_enum(KindEnum.GIFT_WRAP())).since( + Timestamp.from_secs(Timestamp.now().as_secs() - 60 * 60 * 24 * 7)) kinds = [EventDefinitions.KIND_NIP90_GENERIC, EventDefinitions.KIND_FEEDBACK] for dvm in self.dvm_config.SUPPORTED_DVMS: if dvm.KIND not in kinds: - kinds.append(dvm.KIND + 1000) + kinds.append(Kind(dvm.KIND.as_u64() + 1000)) dvm_filter = (Filter().kinds(kinds).since(Timestamp.now())) - self.client.subscribe([zap_filter, dm_filter, dvm_filter]) + self.client.subscribe([zap_filter, dm_filter, nip59_filter, dvm_filter], None) create_sql_table(self.dvm_config.DB) admin_make_database_updates(adminconfig=self.admin_config, dvmconfig=self.dvm_config, client=self.client) + # add_sql_table_column(dvm_config.DB) + class NotificationHandler(HandleNotification): client = self.client dvm_config = self.dvm_config keys = self.keys - def handle(self, relay_url, nostr_event): - if (EventDefinitions.KIND_NIP90_EXTRACT_TEXT + 1000 <= nostr_event.kind() - <= EventDefinitions.KIND_NIP90_GENERIC + 1000): + def handle(self, relay_url, subscription_id, nostr_event): + if (EventDefinitions.KIND_NIP90_EXTRACT_TEXT.as_u64() + 1000 <= nostr_event.kind().as_u64() + <= EventDefinitions.KIND_NIP90_GENERIC.as_u64() + 1000): handle_nip90_response_event(nostr_event) elif nostr_event.kind() == EventDefinitions.KIND_FEEDBACK: handle_nip90_feedback(nostr_event) - elif nostr_event.kind() == EventDefinitions.KIND_DM: - handle_dm(nostr_event) + elif nostr_event.kind() == EventDefinitions.KIND_ZAP: handle_zap(nostr_event) + elif nostr_event.kind() == EventDefinitions.KIND_DM: + try: + handle_dm(nostr_event, False) + except Exception as e: + print(f"Error during content NIP04 decryption: {e}") + elif nostr_event.kind().match_enum(KindEnum.GIFT_WRAP()): + try: + handle_dm(nostr_event, True) + except Exception as e: + print(f"Error during content NIP59 decryption: {e}") + def handle_msg(self, relay_url, msg): return - def handle_dm(nostr_event): + def handle_dm(nostr_event, giftwrap): sender = nostr_event.author().to_hex() if sender == self.keys.public_key().to_hex(): return - + decrypted_text = "" try: - decrypted_text = nip04_decrypt(self.keys.secret_key(), nostr_event.author(), nostr_event.content()) - user = get_or_add_user(db=self.dvm_config.DB, npub=sender, client=self.client, config=self.dvm_config) - print("[" + self.NAME + "] Message from " + user.name + ": " + decrypted_text) + sealed = " " + if giftwrap: + try: + # Extract rumor + unwrapped_gift = UnwrappedGift.from_gift_wrap(self.keys, nostr_event) + sender = unwrapped_gift.sender().to_hex() + rumor: UnsignedEvent = unwrapped_gift.rumor() - # if user selects an index from the overview list... - if decrypted_text[0].isdigit(): - split = decrypted_text.split(' ') - index = int(split[0]) - 1 - # if user sends index info, e.g. 1 info, we fetch the nip89 information and reply with it. - if len(split) > 1 and split[1].lower() == "info": - answer_nip89(nostr_event, index) - # otherwise we probably have to do some work, so build an event from input and send it to the DVM - else: - 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)) - - if user.isblacklisted: - # If users are blacklisted for some reason, tell them. - answer_blacklisted(nostr_event) - - else: - # Parse inputs to params - tags = build_params(decrypted_text, nostr_event, index) - p_tag = Tag.parse(['p', self.dvm_config.SUPPORTED_DVMS[index].PUBLIC_KEY]) - - if self.dvm_config.SUPPORTED_DVMS[index].SUPPORTS_ENCRYPTION: - tags_str = [] - for tag in tags: - tags_str.append(tag.as_vec()) - params_as_str = json.dumps(tags_str) - print(params_as_str) - # and encrypt them - encrypted_params = nip04_encrypt(self.keys.secret_key(), - PublicKey.from_hex( - self.dvm_config.SUPPORTED_DVMS[index].PUBLIC_KEY), - params_as_str) - # add encrypted and p tag on the outside - encrypted_tag = Tag.parse(['encrypted']) - # add the encrypted params to the content - nip90request = (EventBuilder(self.dvm_config.SUPPORTED_DVMS[index].KIND, - encrypted_params, [p_tag, encrypted_tag]). - to_event(self.keys)) + # Check timestamp of rumor + if rumor.created_at().as_secs() >= Timestamp.now().as_secs(): + if rumor.kind().match_enum(KindEnum.SEALED_DIRECT()): + decrypted_text = rumor.content() + print(f"Received new msg [sealed]: {decrypted_text}") + sealed = " [sealed] " + # client.send_sealed_msg(sender, f"Echo: {msg}", None) else: - tags.append(p_tag) - - nip90request = (EventBuilder(self.dvm_config.SUPPORTED_DVMS[index].KIND, - "", tags). - to_event(self.keys)) - - # remember in the job_list that we have made an event, if anybody asks for payment, - # we know we actually sent the request - entry = {"npub": user.npub, "event_id": nip90request.id().to_hex(), - "dvm_key": self.dvm_config.SUPPORTED_DVMS[index].PUBLIC_KEY, "is_paid": False} - self.job_list.append(entry) - - # send the event to the DVM - send_event(nip90request, client=self.client, dvm_config=self.dvm_config) - # print(nip90request.as_json()) - - - - elif decrypted_text.lower().startswith("balance"): - time.sleep(3.0) - evt = EventBuilder.encrypted_direct_msg(self.keys, nostr_event.author(), - "Your current balance is " + str( - user.balance) + " Sats. Zap me to add to your balance. I will use your balance interact with the DVMs for you.\n" - "I support both public and private Zaps, as well as Zapplepay.\n" - "Alternativly you can add a #cashu token with \"-cashu cashuASomeToken\" to your command.\n Make sure the token is worth the requested amount + " - "mint fees (at least 3 sat).\n Not all DVMs might accept Cashu tokens." - , None).to_event(self.keys) - send_event(evt, client=self.client, dvm_config=dvm_config) - - elif decrypted_text.startswith("cashuA"): - print("Received Cashu token:" + decrypted_text) - cashu_redeemed, cashu_message, total_amount, fees = redeem_cashu(decrypted_text, self.dvm_config, - self.client) - print(cashu_message) - if cashu_message == "success": - update_user_balance(self.dvm_config.DB, sender, total_amount, client=self.client, - config=self.dvm_config) - else: - time.sleep(2.0) - message = "Error: " + cashu_message + ". Token has not been redeemed." - evt = EventBuilder.encrypted_direct_msg(self.keys, PublicKey.from_hex(sender), message, - None).to_event(self.keys) - send_event(evt, client=self.client, dvm_config=self.dvm_config) - elif decrypted_text.lower().startswith("what's the second best"): - time.sleep(3.0) - evt = EventBuilder.encrypted_direct_msg(self.keys, nostr_event.author(), - "No, there is no second best.\n\nhttps://cdn.nostr.build/p/mYLv.mp4", - nostr_event.id()).to_event(self.keys) - send_event(evt, client=self.client, dvm_config=self.dvm_config) + print(f"{rumor.as_json()}") + except Exception as e: + print(f"Error during content NIP59 decryption: {e}") else: - # Build an overview of known DVMs and send it to the user - answer_overview(nostr_event) + try: + decrypted_text = nip04_decrypt(self.keys.secret_key(), nostr_event.author(), + nostr_event.content()) + except Exception as e: + print(f"Error during content NIP04 decryption: {e}") + + if decrypted_text != "": + user = get_or_add_user(db=self.dvm_config.DB, npub=sender, client=self.client, + config=self.dvm_config) + + print("[" + self.NAME + "]" + sealed + "Message from " + user.name + ": " + decrypted_text) + + # if user selects an index from the overview list... + if decrypted_text != "" and decrypted_text[0].isdigit(): + + split = decrypted_text.split(' ') + index = int(split[0]) - 1 + # if user sends index info, e.g. 1 info, we fetch the nip89 information and reply with it. + if len(split) > 1 and split[1].lower() == "info": + answer_nip89(nostr_event, index, giftwrap, sender) + # otherwise we probably have to do some work, so build an event from input and send it to the DVM + else: + 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)) + + if user.isblacklisted: + # If users are blacklisted for some reason, tell them. + answer_blacklisted(nostr_event, giftwrap, sender) + + else: + # Parse inputs to params + tags = build_params(decrypted_text, sender, index) + p_tag = Tag.parse(['p', self.dvm_config.SUPPORTED_DVMS[index].PUBLIC_KEY]) + + if self.dvm_config.SUPPORTED_DVMS[index].SUPPORTS_ENCRYPTION: + tags_str = [] + for tag in tags: + tags_str.append(tag.as_vec()) + params_as_str = json.dumps(tags_str) + print(params_as_str) + # and encrypt them + encrypted_params = nip04_encrypt(self.keys.secret_key(), + PublicKey.from_hex( + self.dvm_config.SUPPORTED_DVMS[ + index].PUBLIC_KEY), + params_as_str) + # add encrypted and p tag on the outside + encrypted_tag = Tag.parse(['encrypted']) + # add the encrypted params to the content + nip90request = (EventBuilder(self.dvm_config.SUPPORTED_DVMS[index].KIND, + encrypted_params, [p_tag, encrypted_tag]). + to_event(self.keys)) + else: + tags.append(p_tag) + + nip90request = (EventBuilder(self.dvm_config.SUPPORTED_DVMS[index].KIND, + "", tags). + to_event(self.keys)) + + # remember in the job_list that we have made an event, if anybody asks for payment, + # we know we actually sent the request + entry = {"npub": user.npub, "event_id": nip90request.id().to_hex(), + "dvm_key": self.dvm_config.SUPPORTED_DVMS[index].PUBLIC_KEY, "is_paid": False, "giftwrap": giftwrap} + self.job_list.append(entry) + + # send the event to the DVM + send_event(nip90request, client=self.client, dvm_config=self.dvm_config) + # print(nip90request.as_json()) + + + + elif decrypted_text.lower().startswith("balance"): + time.sleep(3.0) + message = "Your current balance is " + str(user.balance) + ("Sats. Zap me to add to your " + "balance. I will use your " + "balance interact with the DVMs " + "for you.\n I support both " + "public and private Zaps, " + "as well as " + "Zapplepay.\nAlternativly you " + "can add a #cashu token with " + "\"-cashu cashuASomeToken\" to " + "your command.\n Make sure the " + "token is worth the requested " + "amount mint fees (at least 3 " + "sat).\n Not all DVMs might " + "accept Cashu tokens.") + if giftwrap: + self.client.send_sealed_msg(PublicKey.parse(sender), message, None) + else: + evt = EventBuilder.encrypted_direct_msg(self.keys, PublicKey.parse(sender), + message,None).to_event(self.keys) + send_event(evt, client=self.client, dvm_config=dvm_config) + elif decrypted_text.startswith("cashuA"): + print("Received Cashu token:" + decrypted_text) + cashu_redeemed, cashu_message, total_amount, fees = redeem_cashu(decrypted_text, + self.dvm_config, + self.client) + print(cashu_message) + if cashu_message == "success": + update_user_balance(self.dvm_config.DB, sender, total_amount, client=self.client, + config=self.dvm_config) + else: + time.sleep(2.0) + message = "Error: " + cashu_message + ". Token has not been redeemed." + + if giftwrap: + self.client.send_sealed_msg(PublicKey.parse(sender), message, None) + else: + evt = EventBuilder.encrypted_direct_msg(self.keys, PublicKey.from_hex(sender), message, + None).to_event(self.keys) + send_event(evt, client=self.client, dvm_config=self.dvm_config) + elif decrypted_text.lower().startswith("what's the second best"): + time.sleep(3.0) + message = "No, there is no second best.\n\nhttps://cdn.nostr.build/p/mYLv.mp4" + if giftwrap: + self.client.send_sealed_msg(PublicKey.parse(sender), message, None) + else: + evt = EventBuilder.encrypted_direct_msg(self.keys, PublicKey.parse(sender), + message, + nostr_event.id()).to_event(self.keys) + send_event(evt, client=self.client, dvm_config=self.dvm_config) + + else: + # Build an overview of known DVMs and send it to the user + answer_overview(nostr_event, giftwrap, sender) except Exception as e: print("Error in bot " + str(e)) def handle_nip90_feedback(nostr_event): - print(nostr_event.as_json()) + # print(nostr_event.as_json()) try: is_encrypted = False status = "" @@ -211,7 +277,7 @@ class Bot: if is_encrypted: if ptag == self.keys.public_key().to_hex(): - tags_str = nip04_decrypt(Keys.from_sk_str(dvm_config.PRIVATE_KEY).secret_key(), + tags_str = nip04_decrypt(Keys.parse(dvm_config.PRIVATE_KEY).secret_key(), nostr_event.author(), nostr_event.content()) params = json.loads(tags_str) params.append(Tag.parse(["p", ptag]).as_vec()) @@ -240,14 +306,18 @@ class Bot: user = get_or_add_user(db=self.dvm_config.DB, npub=entry['npub'], client=self.client, config=self.dvm_config) time.sleep(2.0) - reply_event = EventBuilder.encrypted_direct_msg(self.keys, + if entry["giftwrap"]: + self.client.send_sealed_msg(PublicKey.parse(entry["npub"]), content, None) + else: + reply_event = EventBuilder.encrypted_direct_msg(self.keys, PublicKey.from_hex(entry['npub']), content, None).to_event(self.keys) + + send_event(reply_event, client=self.client, dvm_config=dvm_config) print(status + ": " + content) print( "[" + self.NAME + "] Received reaction from " + nostr_event.author().to_hex() + " message to orignal sender " + user.name) - send_event(reply_event, client=self.client, dvm_config=dvm_config) elif status == "payment-required" or status == "partial": for tag in nostr_event.tags(): @@ -265,29 +335,31 @@ class Bot: update_sql_table(db=self.dvm_config.DB, npub=user.npub, balance=balance, iswhitelisted=user.iswhitelisted, isblacklisted=user.isblacklisted, nip05=user.nip05, lud16=user.lud16, name=user.name, - lastactive=Timestamp.now().as_secs()) - evt = EventBuilder.encrypted_direct_msg(self.keys, - PublicKey.from_hex(entry["npub"]), - "Paid " + str( - amount) + " Sats from balance to DVM. New balance is " + - str(balance) - + " Sats.\n", - None).to_event(self.keys) + lastactive=Timestamp.now().as_secs(), subscribed=user.subscribed) + message = "Paid " + str(amount) + " Sats from balance to DVM. New balance is " + str(balance) + " Sats.\n" + if entry["giftwrap"]: + self.client.send_sealed_msg(PublicKey.parse(entry["npub"]), message, None) + else: + evt = EventBuilder.encrypted_direct_msg(self.keys, + PublicKey.parse(entry["npub"]), + message, + None).to_event(self.keys) + send_event(evt, client=self.client, dvm_config=dvm_config) print( "[" + self.NAME + "] Replying " + user.name + " with \"scheduled\" confirmation") - send_event(evt, client=self.client, dvm_config=dvm_config) + else: print("Bot payment-required") time.sleep(2.0) evt = EventBuilder.encrypted_direct_msg(self.keys, - PublicKey.from_hex(entry["npub"]), - "Current balance: " + str( - user.balance) + " Sats. Balance of " + str( - amount) + " Sats required. Please zap me with at least " + - str(int(amount - user.balance)) - + " Sats, then try again.", - None).to_event(self.keys) + PublicKey.parse(entry["npub"]), + "Current balance: " + str( + user.balance) + " Sats. Balance of " + str( + amount) + " Sats required. Please zap me with at least " + + str(int(amount - user.balance)) + + " Sats, then try again.", + None).to_event(self.keys) send_event(evt, client=self.client, dvm_config=dvm_config) return @@ -346,7 +418,7 @@ class Bot: return dvms = [x for x in self.dvm_config.SUPPORTED_DVMS if - x.PUBLIC_KEY == nostr_event.author().to_hex() and x.KIND == nostr_event.kind() - 1000] + x.PUBLIC_KEY == nostr_event.author().to_hex() and x.KIND.as_u64() == nostr_event.kind().as_u64() - 1000] if len(dvms) > 0: dvm = dvms[0] if dvm.dvm_config.EXTERNAL_POST_PROCESS_TYPE != PostProcessFunctionType.NONE: @@ -357,11 +429,14 @@ class Bot: print("[" + self.NAME + "] Received results, message to orignal sender " + user.name) time.sleep(1.0) - reply_event = EventBuilder.encrypted_direct_msg(self.keys, - PublicKey.from_hex(user.npub), + if entry["giftwrap"]: + self.client.send_sealed_msg(PublicKey.parse(user.npub), content, None) + else: + reply_event = EventBuilder.encrypted_direct_msg(self.keys, + PublicKey.parse(user.npub), content, None).to_event(self.keys) - send_event(reply_event, client=self.client, dvm_config=dvm_config) + send_event(reply_event, client=self.client, dvm_config=dvm_config) except Exception as e: print(e) @@ -412,7 +487,7 @@ class Bot: except Exception as e: print("[" + self.NAME + "] Error during content decryption:" + str(e)) - def answer_overview(nostr_event): + def answer_overview(nostr_event, giftwrap, sender): message = "DVMs that I support:\n\n" index = 1 for p in self.dvm_config.SUPPORTED_DVMS: @@ -425,37 +500,41 @@ class Bot: index += 1 time.sleep(3.0) - evt = EventBuilder.encrypted_direct_msg(self.keys, nostr_event.author(), - message + "\nSelect an Index and provide an input (" - "e.g. \"2 A purple ostrich\")\nType \"index info\" to learn " - "more about each DVM. (e.g. \"2 info\")\n\n" - "Type \"balance\" to see your current balance", + + text = message + "\nSelect an Index and provide an input (e.g. \"2 A purple ostrich\")\nType \"index info\" to learn more about each DVM. (e.g. \"2 info\")\n\n Type \"balance\" to see your current balance" + if giftwrap: + self.client.send_sealed_msg(PublicKey.parse(sender), text, None) + else: + evt = EventBuilder.encrypted_direct_msg(self.keys, PublicKey.parse(sender), + text, nostr_event.id()).to_event(self.keys) - send_event(evt, client=self.client, dvm_config=dvm_config) + send_event(evt, client=self.client, dvm_config=dvm_config) - def answer_blacklisted(nostr_event): - # For some reason an admin might blacklist npubs, e.g. for abusing the service - evt = EventBuilder.encrypted_direct_msg(self.keys, nostr_event.author(), - "Your are currently blocked from all " - "services.", None).to_event(self.keys) - send_event(evt, client=self.client, dvm_config=dvm_config) - - def answer_nip89(nostr_event, index): - info = print_dvm_info(self.client, index) - time.sleep(2.0) - if info is not None: - evt = EventBuilder.encrypted_direct_msg(self.keys, nostr_event.author(), - info, None).to_event(self.keys) + def answer_blacklisted(nostr_event, giftwrap, sender): + message = "Your are currently blocked from this service." + if giftwrap: + self.client.send_sealed_msg(PublicKey.parse(sender), message, None) else: + # For some reason an admin might blacklist npubs, e.g. for abusing the service evt = EventBuilder.encrypted_direct_msg(self.keys, nostr_event.author(), - "No NIP89 Info found for " + - self.dvm_config.SUPPORTED_DVMS[index].NAME, - None).to_event(self.keys) + message, None).to_event(self.keys) + send_event(evt, client=self.client, dvm_config=dvm_config) - send_event(evt, client=self.client, dvm_config=dvm_config) + def answer_nip89(nostr_event, index, giftwrap, sender): + info = print_dvm_info(self.client, index) + if info is None: + info = "No NIP89 Info found for " + self.dvm_config.SUPPORTED_DVMS[index].NAME + time.sleep(2.0) - def build_params(decrypted_text, nostr_event, index): + if giftwrap: + self.client.send_sealed_msg(PublicKey.parse(sender), info, None) + else: + evt = EventBuilder.encrypted_direct_msg(self.keys, nostr_event.author(), + info, None).to_event(self.keys) + send_event(evt, client=self.client, dvm_config=dvm_config) + + def build_params(decrypted_text, author, index): tags = [] splitzero = decrypted_text.split(' -') split = splitzero[0].split(' ') @@ -463,7 +542,7 @@ class Bot: if len(split) == 1: remaining_text = decrypted_text.replace(split[0], "") params = remaining_text.split(" -") - tag = Tag.parse(["param", "user", nostr_event.author().to_hex()]) + tag = Tag.parse(["param", "user", author]) tags.append(tag) for i in params: print(i) diff --git a/nostr_dvm/dvm.py b/nostr_dvm/dvm.py index dcd2a05..405a626 100644 --- a/nostr_dvm/dvm.py +++ b/nostr_dvm/dvm.py @@ -5,7 +5,7 @@ from datetime import timedelta from sys import platform from nostr_sdk import PublicKey, Keys, Client, Tag, Event, EventBuilder, Filter, HandleNotification, Timestamp, \ - init_logger, LogLevel, Options, nip04_encrypt, ClientSigner + init_logger, LogLevel, Options, nip04_encrypt, NostrSigner, Kind import time @@ -13,18 +13,16 @@ from nostr_dvm.utils.definitions import EventDefinitions, RequiredJobToWatch, Jo from nostr_dvm.utils.dvmconfig import DVMConfig from nostr_dvm.utils.admin_utils import admin_make_database_updates, AdminConfig from nostr_dvm.utils.backend_utils import get_amount_per_task, check_task_is_supported, get_task -from nostr_dvm.utils.database_utils import create_sql_table, get_or_add_user, update_user_balance, update_sql_table +from nostr_dvm.utils.database_utils import create_sql_table, get_or_add_user, update_user_balance, update_sql_table, \ + update_user_subscription from nostr_dvm.utils.mediasource_utils import input_data_file_duration +from nostr_dvm.utils.nip88_utils import nip88_has_active_subscription from nostr_dvm.utils.nostr_utils import get_event_by_id, get_referenced_event_by_id, send_event, check_and_decrypt_tags from nostr_dvm.utils.output_utils import build_status_reaction from nostr_dvm.utils.zap_utils import check_bolt11_ln_bits_is_paid, create_bolt11_ln_bits, parse_zap_event_tags, \ parse_amount_from_bolt11_invoice, zaprequest, pay_bolt11_ln_bits, create_bolt11_lud16 from nostr_dvm.utils.cashu_utils import redeem_cashu -use_logger = False -if use_logger: - init_logger(LogLevel.DEBUG) - class DVM: dvm_config: DVMConfig @@ -37,15 +35,14 @@ class DVM: def __init__(self, dvm_config, admin_config=None): self.dvm_config = dvm_config self.admin_config = admin_config - self.keys = Keys.from_sk_str(dvm_config.PRIVATE_KEY) - wait_for_send = True + self.keys = Keys.parse(dvm_config.PRIVATE_KEY) + wait_for_send = False skip_disconnected_relays = True opts = (Options().wait_for_send(wait_for_send).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT)) .skip_disconnected_relays(skip_disconnected_relays)) - signer = ClientSigner.keys(self.keys) - self.client = Client.with_opts(signer,opts) - + signer = NostrSigner.keys(self.keys) + self.client = Client.with_opts(signer, opts) self.job_list = [] self.jobs_on_hold_list = [] @@ -64,7 +61,8 @@ class DVM: if dvm.KIND not in kinds: kinds.append(dvm.KIND) dvm_filter = (Filter().kinds(kinds).since(Timestamp.now())) - self.client.subscribe([dvm_filter, zap_filter]) + + self.client.subscribe([dvm_filter, zap_filter], None) create_sql_table(self.dvm_config.DB) admin_make_database_updates(adminconfig=self.admin_config, dvmconfig=self.dvm_config, client=self.client) @@ -74,48 +72,103 @@ class DVM: dvm_config = self.dvm_config keys = self.keys - def handle(self, relay_url, nostr_event): - if EventDefinitions.KIND_NIP90_EXTRACT_TEXT <= nostr_event.kind() <= EventDefinitions.KIND_NIP90_GENERIC: + def handle(self, relay_url, subscription_id, nostr_event: Event): + + if EventDefinitions.KIND_NIP90_EXTRACT_TEXT.as_u64() <= nostr_event.kind().as_u64() <= EventDefinitions.KIND_NIP90_GENERIC.as_u64(): handle_nip90_job_event(nostr_event) - elif nostr_event.kind() == EventDefinitions.KIND_ZAP: + elif nostr_event.kind().as_u64() == EventDefinitions.KIND_ZAP.as_u64(): handle_zap(nostr_event) def handle_msg(self, relay_url, msg): return def handle_nip90_job_event(nip90_event): - + # decrypted encrypted events nip90_event = check_and_decrypt_tags(nip90_event, self.dvm_config) + # if event is encrypted, but we can't decrypt it (e.g. because its directed to someone else), return if nip90_event is None: return - - user = get_or_add_user(self.dvm_config.DB, nip90_event.author().to_hex(), client=self.client, - config=self.dvm_config) + + task_is_free = False + user_has_active_subscription = False cashu = "" p_tag_str = "" + for tag in nip90_event.tags(): if tag.as_vec()[0] == "cashu": cashu = tag.as_vec()[1] elif tag.as_vec()[0] == "p": p_tag_str = tag.as_vec()[1] + if p_tag_str != "" and p_tag_str != self.dvm_config.PUBLIC_KEY: + print("[" + self.dvm_config.NIP89.NAME + "] No public request, also not addressed to me.") + return + + + # check if task is supported by the current DVM task_supported, task = check_task_is_supported(nip90_event, client=self.client, config=self.dvm_config) + # if task is supported, continue, else do nothing. + if task_supported: + # fetch or add user contacting the DVM from/to local database + user = get_or_add_user(self.dvm_config.DB, nip90_event.author().to_hex(), client=self.client, + config=self.dvm_config, skip_meta=False) + # if user is blacklisted for some reason, send an error reaction and return + if user.isblacklisted: + send_job_status_reaction(nip90_event, "error", client=self.client, dvm_config=self.dvm_config) + print("[" + self.dvm_config.NIP89.NAME + "] Request by blacklisted user, skipped") + return - if user.isblacklisted: - send_job_status_reaction(nip90_event, "error", client=self.client, dvm_config=self.dvm_config) - print("[" + self.dvm_config.NIP89.NAME + "] Request by blacklisted user, skipped") - - elif task_supported: print("[" + self.dvm_config.NIP89.NAME + "] Received new Request: " + task + " from " + user.name) duration = input_data_file_duration(nip90_event, dvm_config=self.dvm_config, client=self.client) amount = get_amount_per_task(task, self.dvm_config, duration) if amount is None: return - task_is_free = False + + # If this is a subscription DVM and the Task is directed to us, check for active subscription + if dvm_config.NIP88 is not None and p_tag_str == self.dvm_config.PUBLIC_KEY: + send_job_status_reaction(nip90_event, "subscription-required", True, amount, self.client, + "Checking Subscription Status, please wait..",self.dvm_config) + # if we stored in the database that the user has an active subscription, we don't need to check it + print("User Subscription: " + str(user.subscribed) + " Current time: " + str( + Timestamp.now().as_secs())) + # if we have an entry in the db that user is subscribed, continue + if int(user.subscribed) > int(Timestamp.now().as_secs()): + print("User subscribed until: " + str(Timestamp.from_secs(user.subscribed).to_human_datetime())) + user_has_active_subscription = True + send_job_status_reaction(nip90_event, "subscription-required", True, amount, + self.client, "User subscripton active until " + + Timestamp.from_secs(int(user.subscribed)).to_human_datetime().replace("Z", " ").replace("T", " ") + " GMT", self.dvm_config) + # otherwise we check for an active subscription by checking recipie events + else: + print("[" + self.dvm_config.NIP89.NAME + "] Checking Subscription status") + send_job_status_reaction(nip90_event, "subscription-required", True, amount, self.client, + "I Don't have information about subscription status, checking on the Nostr. This might take a few seconds", + self.dvm_config) + + subscription_status = nip88_has_active_subscription(PublicKey.parse(user.npub), + self.dvm_config.NIP88.DTAG, self.client, + self.dvm_config.PUBLIC_KEY) + + if subscription_status["isActive"]: + send_job_status_reaction(nip90_event, "subscription-required", True, amount, self.client, + "User subscripton active until " + Timestamp.from_secs(int(subscription_status["validUntil"])).to_human_datetime().replace("Z", " ").replace("T", " ") + " GMT", + self.dvm_config) + print("Checked Recipe: User subscribed until: " + str( + Timestamp.from_secs(int(subscription_status["validUntil"])).to_human_datetime())) + user_has_active_subscription = True + update_user_subscription(user.npub, + int(subscription_status["validUntil"]), + self.client, self.dvm_config) + else: + print("No active subscription found") + send_job_status_reaction(nip90_event, "subscription-required", True, amount, self.client, + "No active subscription found. Manage your subscription at: " + self.dvm_config.SUBSCRIPTION_MANAGEMENT, + self.dvm_config) + for dvm in self.dvm_config.SUPPORTED_DVMS: - if dvm.TASK == task and dvm.FIX_COST == 0 and dvm.PER_UNIT_COST == 0: + if dvm.TASK == task and dvm.FIX_COST == 0 and dvm.PER_UNIT_COST == 0 and dvm_config.NIP88 is None: task_is_free = True cashu_redeemed = False @@ -129,31 +182,40 @@ class DVM: self.dvm_config) return # if user is whitelisted or task is free, just do the job - if (user.iswhitelisted or task_is_free or cashu_redeemed) and (p_tag_str == "" or p_tag_str == - self.dvm_config.PUBLIC_KEY): + if (user.iswhitelisted or task_is_free or cashu_redeemed) and ( + p_tag_str == "" or p_tag_str == + self.dvm_config.PUBLIC_KEY): print( "[" + self.dvm_config.NIP89.NAME + "] Free task or Whitelisted for task " + task + ". Starting processing..") if dvm_config.SEND_FEEDBACK_EVENTS: send_job_status_reaction(nip90_event, "processing", True, 0, - client=self.client, dvm_config=self.dvm_config) + client=self.client, dvm_config=self.dvm_config, user=user) # when we reimburse users on error make sure to not send anything if it was free if user.iswhitelisted or task_is_free: amount = 0 do_work(nip90_event, amount) - # if task is directed to us via p tag and user has balance, do the job and update balance - elif p_tag_str == self.dvm_config.PUBLIC_KEY and user.balance >= int(amount): - balance = max(user.balance - int(amount), 0) - update_sql_table(db=self.dvm_config.DB, npub=user.npub, balance=balance, - iswhitelisted=user.iswhitelisted, isblacklisted=user.isblacklisted, - nip05=user.nip05, lud16=user.lud16, name=user.name, - lastactive=Timestamp.now().as_secs()) + # if task is directed to us via p tag and user has balance or is subscribed, do the job and update balance + elif (p_tag_str == self.dvm_config.PUBLIC_KEY and ( + user.balance >= int( + amount) and dvm_config.NIP88 is None) or ( + p_tag_str == self.dvm_config.PUBLIC_KEY and user_has_active_subscription)): - print( - "[" + self.dvm_config.NIP89.NAME + "] Using user's balance for task: " + task + - ". Starting processing.. New balance is: " + str(balance)) + if not user_has_active_subscription: + balance = max(user.balance - int(amount), 0) + update_sql_table(db=self.dvm_config.DB, npub=user.npub, balance=balance, + iswhitelisted=user.iswhitelisted, isblacklisted=user.isblacklisted, + nip05=user.nip05, lud16=user.lud16, name=user.name, + lastactive=Timestamp.now().as_secs(), subscribed=user.subscribed) + + print( + "[" + self.dvm_config.NIP89.NAME + "] Using user's balance for task: " + task + + ". Starting processing.. New balance is: " + str(balance)) + else: + print("[" + self.dvm_config.NIP89.NAME + "] User has active subscription for task: " + task + + ". Starting processing.. Balance remains at: " + str(user.balance)) send_job_status_reaction(nip90_event, "processing", True, 0, client=self.client, dvm_config=self.dvm_config) @@ -162,27 +224,40 @@ class DVM: # else send a payment required event to user elif p_tag_str == "" or p_tag_str == self.dvm_config.PUBLIC_KEY: - bid = 0 - for tag in nip90_event.tags(): - 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()) - if bid > 0: - bid_offer = int(bid / 1000) - if bid_offer >= int(amount): - send_job_status_reaction(nip90_event, "payment-required", False, - int(amount), # bid_offer - client=self.client, dvm_config=self.dvm_config) - - else: # If there is no bid, just request server rate from user + if dvm_config.NIP88 is not None: print( - "[" + self.dvm_config.NIP89.NAME + "] Requesting payment for Event: " + + "[" + self.dvm_config.NIP89.NAME + "] Hinting user for Subscription: " + nip90_event.id().to_hex()) - send_job_status_reaction(nip90_event, "payment-required", - False, int(amount), client=self.client, dvm_config=self.dvm_config) + send_job_status_reaction(nip90_event, "subscription-required", + False, 0, client=self.client, + dvm_config=self.dvm_config) + else: + bid = 0 + for tag in nip90_event.tags(): + 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()) + if bid > 0: + bid_offer = int(bid / 1000) + if bid_offer >= int(amount): + send_job_status_reaction(nip90_event, "payment-required", False, + int(amount), # bid_offer + 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()) + send_job_status_reaction(nip90_event, "payment-required", + False, int(amount), client=self.client, dvm_config=self.dvm_config) + + + + else: print("[" + self.dvm_config.NIP89.NAME + "] Job addressed to someone else, skipping..") # else: @@ -202,6 +277,7 @@ class DVM: amount = 0 job_event = None p_tag_str = "" + status = "" for tag in zapped_event.tags(): if tag.as_vec()[0] == 'amount': amount = int(float(tag.as_vec()[1]) / 1000) @@ -213,41 +289,53 @@ class DVM: return else: return + elif tag.as_vec()[0] == 'status': + status = tag.as_vec()[1] + print(status) # if a reaction by us got zapped + print(status) + if job_event.kind() == EventDefinitions.KIND_NIP88_SUBSCRIBE_EVENT: + send_job_status_reaction(job_event, "subscription-success", client=self.client, + dvm_config=self.dvm_config, user=user) - task_supported, task = check_task_is_supported(job_event, client=self.client, - config=self.dvm_config) - if job_event is not None and task_supported: - print("Zap received for NIP90 task: " + str(invoice_amount) + " Sats from " + str( - user.name)) - if amount <= invoice_amount: - print("[" + self.dvm_config.NIP89.NAME + "] Payment-request fulfilled...") - send_job_status_reaction(job_event, "processing", client=self.client, - dvm_config=self.dvm_config) - indices = [i for i, x in enumerate(self.job_list) if - x.event == job_event] - index = -1 - if len(indices) > 0: - index = indices[0] - if index > -1: - if self.job_list[index].is_processed: # If payment-required appears a processing - self.job_list[index].is_paid = True - check_and_return_event(self.job_list[index].result, job_event) - elif not (self.job_list[index]).is_processed: - # If payment-required appears before processing - self.job_list.pop(index) - print("Starting work...") + + + else: + task_supported, task = check_task_is_supported(job_event, client=self.client, + config=self.dvm_config) + if job_event is not None and task_supported: + print("Zap received for NIP90 task: " + str(invoice_amount) + " Sats from " + str( + user.name)) + if amount <= invoice_amount: + print("[" + self.dvm_config.NIP89.NAME + "] Payment-request fulfilled...") + send_job_status_reaction(job_event, "processing", client=self.client, + dvm_config=self.dvm_config, user=user) + indices = [i for i, x in enumerate(self.job_list) if + x.event == job_event] + index = -1 + if len(indices) > 0: + index = indices[0] + if index > -1: + if self.job_list[index].is_processed: + self.job_list[index].is_paid = True + check_and_return_event(self.job_list[index].result, job_event) + elif not (self.job_list[index]).is_processed: + # If payment-required appears before processing + self.job_list.pop(index) + print("Starting work...") + do_work(job_event, invoice_amount) + else: + print("Job not in List, but starting work...") do_work(job_event, invoice_amount) - else: - print("Job not in List, but starting work...") - do_work(job_event, invoice_amount) - else: - send_job_status_reaction(job_event, "payment-rejected", - False, invoice_amount, client=self.client, - dvm_config=self.dvm_config) - print("[" + self.dvm_config.NIP89.NAME + "] Invoice was not paid sufficiently") + else: + send_job_status_reaction(job_event, "payment-rejected", + False, invoice_amount, client=self.client, + dvm_config=self.dvm_config) + print("[" + self.dvm_config.NIP89.NAME + "] Invoice was not paid sufficiently") + elif zapped_event.kind() == EventDefinitions.KIND_NIP88_SUBSCRIBE_EVENT: + print("new subscription, doing nothing") elif zapped_event.kind() in EventDefinitions.ANY_RESULT: print("[" + self.dvm_config.NIP89.NAME + "] " @@ -259,7 +347,7 @@ class DVM: config=self.dvm_config) # a regular note - elif not anon: + elif not anon and dvm_config.NIP88 is None: print("[" + self.dvm_config.NIP89.NAME + "] Profile Zap received for DVM balance: " + str(invoice_amount) + " Sats from " + str(user.name)) update_user_balance(self.dvm_config.DB, sender, invoice_amount, client=self.client, @@ -328,6 +416,7 @@ class DVM: post_processed = dvm.post_process(data, original_event) send_nostr_reply_event(post_processed, original_event.as_json()) except Exception as e: + print(e) # Zapping back by error in post-processing is a risk for the DVM because work has been done, # but maybe something with parsing/uploading failed. Try to avoid errors here as good as possible send_job_status_reaction(original_event, "error", @@ -355,7 +444,7 @@ class DVM: e_tag = Tag.parse(["e", original_event.id().to_hex()]) p_tag = Tag.parse(["p", original_event.author().to_hex()]) alt_tag = Tag.parse(["alt", "This is the result of a NIP90 DVM AI task with kind " + str( - original_event.kind()) + ". The task was: " + original_event.content()]) + original_event.kind().as_u64()) + ". The task was: " + original_event.content()]) status_tag = Tag.parse(["status", "success"]) reply_tags = [request_tag, e_tag, p_tag, alt_tag, status_tag] encrypted = False @@ -376,18 +465,19 @@ class DVM: content = nip04_encrypt(self.keys.secret_key(), PublicKey.from_hex(original_event.author().to_hex()), content) - reply_event = EventBuilder(original_event.kind() + 1000, str(content), reply_tags).to_event(self.keys) + reply_event = EventBuilder(Kind(original_event.kind().as_u64() + 1000), str(content), reply_tags).to_event( + self.keys) send_event(reply_event, client=self.client, dvm_config=self.dvm_config) print("[" + self.dvm_config.NIP89.NAME + "] " + str( - original_event.kind() + 1000) + " Job Response event sent: " + reply_event.as_json()) + original_event.kind().as_u64() + 1000) + " Job Response event sent: " + reply_event.as_json()) def send_job_status_reaction(original_event, status, is_paid=True, amount=0, client=None, content=None, - dvm_config=None): + dvm_config=None, user=None): task = get_task(original_event, client=client, dvm_config=dvm_config) - alt_description, reaction = build_status_reaction(status, task, amount, content) + alt_description, reaction = build_status_reaction(status, task, amount, content, dvm_config) e_tag = Tag.parse(["e", original_event.id().to_hex()]) p_tag = Tag.parse(["p", original_event.author().to_hex()]) @@ -405,9 +495,12 @@ class DVM: if encrypted: encryption_tags.append(p_tag) + encryption_tags.append(e_tag) + else: reply_tags.append(p_tag) + if status == "success" or status == "error": # for x in self.job_list: if x.event == original_event: @@ -418,18 +511,19 @@ class DVM: bolt11 = "" payment_hash = "" expires = original_event.created_at().as_secs() + (60 * 60 * 24) - if status == "payment-required" or (status == "processing" and not is_paid): + if status == "payment-required" or ( + status == "processing" and not is_paid): if dvm_config.LNBITS_INVOICE_KEY != "": try: - bolt11, payment_hash = create_bolt11_ln_bits(amount,dvm_config) + bolt11, payment_hash = create_bolt11_ln_bits(amount, dvm_config) except Exception as e: print(e) try: bolt11, payment_hash = create_bolt11_lud16(dvm_config.LN_ADDRESS, - amount) + amount) except Exception as e: - print(e) - bolt11 = None + print(e) + bolt11 = None elif dvm_config.LN_ADDRESS != "": try: bolt11, payment_hash = create_bolt11_lud16(dvm_config.LN_ADDRESS, amount) @@ -451,7 +545,7 @@ class DVM: status == "processing" and not is_paid) or (status == "success" and not is_paid)): - if dvm_config.LNBITS_INVOICE_KEY != "": + if dvm_config.LNBITS_INVOICE_KEY != "" and bolt11 is not None: amount_tag = Tag.parse(["amount", str(amount * 1000), bolt11]) else: amount_tag = Tag.parse(["amount", str(amount * 1000)]) # to millisats @@ -472,16 +566,17 @@ class DVM: else: content = reaction - keys = Keys.from_sk_str(dvm_config.PRIVATE_KEY) + keys = Keys.parse(dvm_config.PRIVATE_KEY) reaction_event = EventBuilder(EventDefinitions.KIND_FEEDBACK, str(content), reply_tags).to_event(keys) send_event(reaction_event, client=self.client, dvm_config=self.dvm_config) print("[" + self.dvm_config.NIP89.NAME + "]" + ": Sent Kind " + str( - EventDefinitions.KIND_FEEDBACK) + " Reaction: " + status + " " + reaction_event.as_json()) + EventDefinitions.KIND_FEEDBACK.as_u64()) + " Reaction: " + status + " " + reaction_event.as_json()) return reaction_event.as_json() def do_work(job_event, amount): - if ((EventDefinitions.KIND_NIP90_EXTRACT_TEXT <= job_event.kind() <= EventDefinitions.KIND_NIP90_GENERIC) - or job_event.kind() == EventDefinitions.KIND_DM): + if (( + EventDefinitions.KIND_NIP90_EXTRACT_TEXT.as_u64() <= job_event.kind().as_u64() <= EventDefinitions.KIND_NIP90_GENERIC.as_u64()) + or job_event.kind().as_u64() == EventDefinitions.KIND_DM.as_u64()): task = get_task(job_event, client=self.client, dvm_config=self.dvm_config) @@ -520,9 +615,11 @@ class DVM: post_processed = dvm.post_process(result, job_event) send_nostr_reply_event(post_processed, job_event.as_json()) except Exception as e: + print(e) send_job_status_reaction(job_event, "error", content=str(e), dvm_config=self.dvm_config) except Exception as e: + print(e) # we could send the exception here to the user, but maybe that's not a good idea after all. send_job_status_reaction(job_event, "error", content=result, dvm_config=self.dvm_config) @@ -531,7 +628,8 @@ class DVM: user = get_or_add_user(self.dvm_config.DB, job_event.author().to_hex(), client=self.client, config=self.dvm_config) print(user.lud16 + " " + str(amount)) - bolt11 = zaprequest(user.lud16, amount, "Couldn't finish job, returning sats", job_event, user.npub, + bolt11 = zaprequest(user.lud16, amount, "Couldn't finish job, returning sats", job_event, + PublicKey.parse(user.npub), self.keys, self.dvm_config.RELAY_LIST, zaptype="private") if bolt11 is None: print("Receiver has no Lightning address, can't zap back.") @@ -545,18 +643,23 @@ class DVM: self.client.handle_notifications(NotificationHandler()) while True: + + for dvm in self.dvm_config.SUPPORTED_DVMS: + scheduled_result = dvm.schedule(self.dvm_config) + for job in self.job_list: - if job.bolt11 != "" and job.payment_hash != "" and not job.is_paid: + if job.bolt11 != "" and job.payment_hash != "" and not job.payment_hash is None and not job.is_paid: ispaid = check_bolt11_ln_bits_is_paid(job.payment_hash, self.dvm_config) if ispaid and job.is_paid is False: print("is paid") + job.is_paid = True + amount = parse_amount_from_bolt11_invoice(job.bolt11) job.is_paid = True send_job_status_reaction(job.event, "processing", True, 0, client=self.client, dvm_config=self.dvm_config) print("[" + self.dvm_config.NIP89.NAME + "] doing work from joblist") - amount = parse_amount_from_bolt11_invoice(job.bolt11) do_work(job.event, amount) elif ispaid is None: # invoice expired self.job_list.remove(job) diff --git a/nostr_dvm/interfaces/dvmtaskinterface.py b/nostr_dvm/interfaces/dvmtaskinterface.py index c3b2e07..0be20f6 100644 --- a/nostr_dvm/interfaces/dvmtaskinterface.py +++ b/nostr_dvm/interfaces/dvmtaskinterface.py @@ -7,17 +7,18 @@ import sys from sys import platform from threading import Thread from venv import create -from nostr_sdk import Keys +from nostr_sdk import Keys, Kind from nostr_dvm.dvm import DVM from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.output_utils import post_process_result class DVMTaskInterface: NAME: str - KIND: int + KIND: Kind TASK: str = "" FIX_COST: float = 0 PER_UNIT_COST: float = 0 @@ -30,17 +31,18 @@ class DVMTaskInterface: admin_config: AdminConfig dependencies = [] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, admin_config: AdminConfig = None, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, + admin_config: AdminConfig = None, options=None, task=None): - self.init(name, dvm_config, admin_config, nip89config, task) + self.init(name, dvm_config, admin_config, nip88config, nip89config, task) self.options = options self.install_dependencies(dvm_config) - def init(self, name, dvm_config, admin_config=None, nip89config=None, task=None): + def init(self, name, dvm_config, admin_config=None, nip88config=None, nip89config=None, task=None): self.NAME = name self.PRIVATE_KEY = dvm_config.PRIVATE_KEY if dvm_config.PUBLIC_KEY == "" or dvm_config.PUBLIC_KEY is None: - dvm_config.PUBLIC_KEY = Keys.from_sk_str(dvm_config.PRIVATE_KEY).public_key().to_hex() + dvm_config.PUBLIC_KEY = Keys.parse(dvm_config.PRIVATE_KEY).public_key().to_hex() self.PUBLIC_KEY = dvm_config.PUBLIC_KEY if dvm_config.FIX_COST is not None: self.FIX_COST = dvm_config.FIX_COST @@ -55,6 +57,12 @@ class DVMTaskInterface: self.KIND = nip89config.KIND dvm_config.NIP89 = self.NIP89_announcement(nip89config) + + if nip88config is None: + dvm_config.NIP88 = None + else: + dvm_config.NIP88 = nip88config + self.dvm_config = dvm_config self.admin_config = admin_config @@ -86,6 +94,10 @@ class DVMTaskInterface: nostr_dvm_thread = Thread(target=self.DVM, args=[self.dvm_config, self.admin_config]) nostr_dvm_thread.start() + def schedule(self, dvm_config): + """schedule something, e.g. define some time to update or to post, does nothing by default""" + pass + def NIP89_announcement(self, nip89config: NIP89Config): nip89 = NIP89Config() nip89.NAME = self.NAME @@ -146,4 +158,3 @@ def process_venv(identifier): DVMTaskInterface.write_output(result, args.output) except Exception as e: DVMTaskInterface.write_output("Error: " + str(e), args.output) - diff --git a/nostr_dvm/subscription.py b/nostr_dvm/subscription.py new file mode 100644 index 0000000..a593060 --- /dev/null +++ b/nostr_dvm/subscription.py @@ -0,0 +1,446 @@ +import json +import math +import os +import signal +import time +from datetime import timedelta + +from nostr_sdk import (Keys, Client, Timestamp, Filter, nip04_decrypt, HandleNotification, EventBuilder, PublicKey, + Options, Tag, Event, nip04_encrypt, NostrSigner, EventId, Nip19Event, nip44_decrypt, Kind) + +from nostr_dvm.utils.database_utils import fetch_user_metadata +from nostr_dvm.utils.definitions import EventDefinitions +from nostr_dvm.utils.dvmconfig import DVMConfig +from nostr_dvm.utils.nip88_utils import nip88_has_active_subscription +from nostr_dvm.utils.nip89_utils import NIP89Config +from nostr_dvm.utils.nostr_utils import send_event +from nostr_dvm.utils.nwc_tools import nwc_zap +from nostr_dvm.utils.subscription_utils import create_subscription_sql_table, add_to_subscription_sql_table, \ + get_from_subscription_sql_table, update_subscription_sql_table, get_all_subscriptions_from_sql_table, \ + delete_from_subscription_sql_table +from nostr_dvm.utils.zap_utils import create_bolt11_lud16, zaprequest + + +class Subscription: + job_list: list + + # This is a simple list just to keep track which events we created and manage, so we don't pay for other requests + def __init__(self, dvm_config, admin_config=None): + self.NAME = "Subscription Handler" + dvm_config.DB = "db/" + "subscriptions" + ".db" + self.dvm_config = dvm_config + nip89config = NIP89Config() + nip89config.NAME = self.NAME + self.dvm_config.NIP89 = nip89config + self.admin_config = admin_config + self.keys = Keys.parse(dvm_config.PRIVATE_KEY) + wait_for_send = False + skip_disconnected_relays = True + opts = (Options().wait_for_send(wait_for_send).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT)) + .skip_disconnected_relays(skip_disconnected_relays)) + signer = NostrSigner.keys(self.keys) + self.client = Client.with_opts(signer, opts) + + pk = self.keys.public_key() + + self.job_list = [] + + print("Nostr Subscription Handler public key: " + str(pk.to_bech32()) + " Hex: " + str( + pk.to_hex()) + "\n") + + for relay in self.dvm_config.RELAY_LIST: + self.client.add_relay(relay) + self.client.connect() + + zap_filter = Filter().pubkey(pk).kinds([EventDefinitions.KIND_ZAP]).since(Timestamp.now()) + cancel_subscription_filter = Filter().kinds([EventDefinitions.KIND_NIP88_STOP_SUBSCRIPTION_EVENT]).since( + Timestamp.now()) + authors = [] + if admin_config is not None and len(admin_config.USERNPUBS) > 0: + #we might want to limit which services can connect to the subscription handler + for key in admin_config.USERNPUBS: + authors.append(PublicKey.parse(key)) + dvm_filter = Filter().authors(authors).pubkey(pk).kinds([EventDefinitions.KIND_NIP90_DVM_SUBSCRIPTION]).since( + Timestamp.now()) + else: + # or we don't + dvm_filter = Filter().pubkey(pk).kinds( + [EventDefinitions.KIND_NIP90_DVM_SUBSCRIPTION]).since( + Timestamp.now()) + + self.client.subscribe([zap_filter, dvm_filter, cancel_subscription_filter], None) + + create_subscription_sql_table(dvm_config.DB) + + class NotificationHandler(HandleNotification): + client = self.client + dvm_config = self.dvm_config + keys = self.keys + + def handle(self, relay_url, subscription_id, nostr_event: Event): + if nostr_event.kind() == EventDefinitions.KIND_NIP90_DVM_SUBSCRIPTION: + handle_nwc_request(nostr_event) + elif nostr_event.kind() == EventDefinitions.KIND_NIP88_STOP_SUBSCRIPTION_EVENT: + handle_cancel(nostr_event) + + def handle_msg(self, relay_url, msg): + return + + def handle_cancel(nostr_event): + print(nostr_event.as_json()) + sender = nostr_event.author().to_hex() + kind7001eventid = "" + recipient = "" + if sender == self.keys.public_key().to_hex(): + return + + for tag in nostr_event.tags(): + if tag.as_vec()[0] == "p": + recipient = tag.as_vec()[1] + elif tag.as_vec()[0] == "e": + kind7001eventid = tag.as_vec()[1] + + if kind7001eventid != "": + subscription = get_from_subscription_sql_table(dvm_config.DB, kind7001eventid) + + if subscription is not None: + update_subscription_sql_table(dvm_config.DB, kind7001eventid, recipient, + subscription.subscriber, subscription.nwc, subscription.cadence, + subscription.amount, subscription.unit, subscription.begin, + subscription.end, + subscription.tier_dtag, subscription.zaps, subscription.recipe, + False, Timestamp.now().as_secs(), subscription.tier) + + # send_status_canceled(kind7001eventid, nostr_event) # TODO, waiting for spec + + def infer_subscription_end_time(start, cadence): + end = start + if cadence == "daily": + end = start + 60 * 60 * 24 + elif cadence == "weekly": + end = start + 60 * 60 * 24 * 7 + elif cadence == "monthly": + # TODO check days of month -.- + end = start + 60 * 60 * 24 * 31 + elif cadence == "yearly": + # TODO check extra day every 4 years + end = start + 60 * 60 * 24 * 356 + return end + + def send_status_success(original_event, domain): + + e_tag = Tag.parse(["e", original_event.id().to_hex()]) + p_tag = Tag.parse(["p", original_event.author().to_hex()]) + status_tag = Tag.parse(["status", "success", "Job has been scheduled, you can manage it on " + domain]) + reply_tags = [status_tag] + encryption_tags = [] + + encrypted_tag = Tag.parse(["encrypted"]) + encryption_tags.append(encrypted_tag) + encryption_tags.append(p_tag) + encryption_tags.append(e_tag) + + str_tags = [] + for element in reply_tags: + str_tags.append(element.as_vec()) + + content = json.dumps(str_tags) + content = nip04_encrypt(self.keys.secret_key(), PublicKey.from_hex(original_event.author().to_hex()), + content) + reply_tags = encryption_tags + + keys = Keys.parse(dvm_config.PRIVATE_KEY) + reaction_event = EventBuilder(EventDefinitions.KIND_FEEDBACK, str(content), reply_tags).to_event(keys) + send_event(reaction_event, client=self.client, dvm_config=self.dvm_config) + print("[" + self.dvm_config.NIP89.NAME + "]" + ": Sent Kind " + str( + EventDefinitions.KIND_FEEDBACK.as_u64()) + " Reaction: " + "success" + " " + reaction_event.as_json()) + + def pay_zap_split(nwc, overall_amount, zaps, tier, unit="msats"): + overallsplit = 0 + + for zap in zaps: + print(zap) + overallsplit += int(zap[3]) + + print(overallsplit) + zapped_amount = 0 + for zap in zaps: + if zap[1] == "": + #If the client did decide to not add itself to the zap split, or if a slot is left we add the subscription service in the empty space + zap[1] = Keys.parse(self.dvm_config.PRIVATE_KEY).public_key().to_hex() + + name, nip05, lud16 = fetch_user_metadata(zap[1], self.client) + splitted_amount = math.floor( + (int(zap[3]) / overallsplit) * int(overall_amount) / 1000) + # invoice = create_bolt11_lud16(lud16, splitted_amount) + # TODO add details about DVM in message + + invoice = zaprequest(lud16, splitted_amount, tier, None, + PublicKey.parse(zap[1]), self.keys, DVMConfig.RELAY_LIST) + print(invoice) + if invoice is not None: + nwc_event_id = nwc_zap(nwc, invoice, self.keys, zap[2]) + if nwc_event_id is None: + print("error zapping " + lud16) + else: + zapped_amount = zapped_amount + (splitted_amount * 1000) + print(str(zapped_amount) + "/" + str(overall_amount)) + + if zapped_amount < overall_amount * 0.8: # TODO how do we handle failed zaps for some addresses? we are ok with 80% for now + success = False + else: + success = True + # if no active subscription exists OR the subscription ended, pay + + return success + + def make_subscription_zap_recipe(event7001, recipient, subscriber, start, end, tier_dtag): + message = "payed by subscription service" + pTag = Tag.parse(["p", recipient]) + PTag = Tag.parse(["P", subscriber]) + eTag = Tag.parse(["e", event7001]) + validTag = Tag.parse(["valid", str(start), str(end)]) + tierTag = Tag.parse(["tier", tier_dtag]) + alttag = Tag.parse(["alt", "This is a NIP90 DVM Subscription Payment Recipe"]) + + tags = [pTag, PTag, eTag, validTag, tierTag, alttag] + + event = EventBuilder(EventDefinitions.KIND_NIP88_PAYMENT_RECIPE, + message, tags).to_event(self.keys) + + dvmconfig = DVMConfig() + signer = NostrSigner.keys(self.keys) + client = Client(signer) + for relay in dvmconfig.RELAY_LIST: + client.add_relay(relay) + client.connect() + recipeid = client.send_event(event) + recipe = recipeid.to_hex() + return recipe + + def handle_nwc_request(nostr_event): + print(nostr_event.as_json()) + sender = nostr_event.author().to_hex() + if sender == self.keys.public_key().to_hex(): + return + + try: + decrypted_text = nip04_decrypt(self.keys.secret_key(), nostr_event.author(), nostr_event.content()) + try: + jsonevent = json.loads(decrypted_text) + for entry in jsonevent: + if entry[1] == "nwc": + nwc = entry[2] + elif entry[1] == "p": + subscriber = entry[2] + + subscriptionfilter = Filter().kind(EventDefinitions.KIND_NIP88_SUBSCRIBE_EVENT).author( + PublicKey.parse(subscriber)).limit(1) + evts = self.client.get_events_of([subscriptionfilter], timedelta(seconds=3)) + print(evts) + if len(evts) > 0: + event7001id = evts[0].id().to_hex() + print(evts[0].as_json()) + tier_dtag = "" + recipient = "" + cadence = "" + unit = "msats" + zaps = [] + tier = "DVM" + overall_amount = 0 + for tag in evts[0].tags(): + if tag.as_vec()[0] == "amount": + overall_amount = int(tag.as_vec()[1]) + + unit = tag.as_vec()[2] + cadence = tag.as_vec()[3] + print(str(overall_amount) + " " + unit + " " + cadence) + elif tag.as_vec()[0] == "p": + recipient = tag.as_vec()[1] + elif tag.as_vec()[0] == "e": + subscription_event_id = tag.as_vec()[1] + elif tag.as_vec()[0] == "event": + jsonevent = json.loads(tag.as_vec()[1]) + subscription_event = Event.from_json(jsonevent) + + for tag in subscription_event.tags(): + if tag.as_vec()[0] == "d": + tier_dtag = tag.as_vec()[1] + elif tag.as_vec()[0] == "zap": + zaps.append(tag.as_vec()) + elif tag.as_vec()[0] == "title": + tier = tag.as_vec()[1] + + if tier_dtag == "" or len(zaps) == 0: + tierfilter = Filter().id(EventId.parse(subscription_event_id)) + evts = self.client.get_events_of([tierfilter], timedelta(seconds=3)) + if len(evts) > 0: + for tag in evts[0].tags(): + if tag.as_vec()[0] == "d": + tier_dtag = tag.as_vec()[0] + + isactivesubscription = False + recipe = "" + subscription = get_from_subscription_sql_table(dvm_config.DB, event7001id) + + zapsstr = json.dumps(zaps) + print(zapsstr) + success = True + if subscription is None or subscription.end <= Timestamp.now().as_secs(): + # rather check nostr if our db is right + subscription_status = nip88_has_active_subscription( + PublicKey.parse(subscriber), + tier_dtag, self.client, recipient, checkCanceled=False) + + if not subscription_status["isActive"]: + start = Timestamp.now().as_secs() + end = infer_subscription_end_time(start, cadence) + + # we add or update the subscription in the db, with non-active subscription to avoid double payments + if subscription is None: + add_to_subscription_sql_table(dvm_config.DB, event7001id, recipient, subscriber, + nwc, + cadence, overall_amount, unit, start, end, tier_dtag, + zapsstr, recipe, isactivesubscription, + Timestamp.now().as_secs(), tier) + print("new subscription entry before payment") + else: + update_subscription_sql_table(dvm_config.DB, event7001id, recipient, subscriber, + nwc, + cadence, overall_amount, unit, start, end, + tier_dtag, zapsstr, recipe, isactivesubscription, + Timestamp.now().as_secs(), tier) + print("updated subscription entry before payment") + + # we attempt to pay the subscription + success = pay_zap_split(nwc, overall_amount, zaps, tier, unit) + + else: + start = Timestamp.now().as_secs() + end = subscription_status["validUntil"] + else: + start = subscription.begin + end = subscription.end + + if success: + # we create a payment recipe + recipe = make_subscription_zap_recipe(event7001id, recipient, subscriber, start, end, + tier_dtag) + print("RECIPE " + recipe) + isactivesubscription = True + + # we then update the subscription based on payment success + update_subscription_sql_table(dvm_config.DB, event7001id, recipient, subscriber, nwc, + cadence, overall_amount, unit, start, end, + tier_dtag, zapsstr, recipe, isactivesubscription, + Timestamp.now().as_secs(), tier) + print("updated subscription entry after payment") + + send_status_success(nostr_event, "noogle.lol") + + keys = Keys.parse(dvm_config.PRIVATE_KEY) + message = ("Subscribed to DVM " + tier + ". Renewing on: " + str( + Timestamp.from_secs(end).to_human_datetime().replace("Z", " ").replace("T", " ") + " GMT")) + evt = EventBuilder.encrypted_direct_msg(keys, PublicKey.parse(subscriber), message, + None).to_event(keys) + send_event(evt, client=self.client, dvm_config=dvm_config) + + + + except Exception as e: + print(e) + + except Exception as e: + print("Error in Subscriber " + str(e)) + + def handle_expired_subscription(subscription): + delete_threshold = 60 * 60 * 24 * 365 + if subscription.cadence == "daily": + delete_threshold = 60 * 60 * 24 * 3 # After 3 days, delete the subscription, user can make a new one + elif subscription.cadence == "weekly": + delete_threshold = 60 * 60 * 24 * 21 # After 21 days, delete the subscription, user can make a new one + elif subscription.cadence == "monthly": + delete_threshold = 60 * 60 * 24 * 60 # After 60 days, delete the subscription, user can make a new one + elif subscription.cadence == "yearly": + delete_threshold = 60 * 60 * 24 * 500 # After 500 days, delete the subscription, user can make a new one + + if subscription.end < (Timestamp.now().as_secs() - delete_threshold): + delete_from_subscription_sql_table(dvm_config.DB, subscription.id) + print("Delete expired subscription") + + def handle_subscription_renewal(subscription): + zaps = json.loads(subscription.zaps) + success = pay_zap_split(subscription.nwc, subscription.amount, zaps, subscription.tier, + subscription.unit) + if success: + end = infer_subscription_end_time(Timestamp.now().as_secs(), subscription.cadence) + recipe = make_subscription_zap_recipe(subscription.id, subscription.recipent, + subscription.subscriber, subscription.begin, + end, subscription.tier_dtag) + else: + end = Timestamp.now().as_secs() + recipe = subscription.recipe + + update_subscription_sql_table(dvm_config.DB, subscription.id, + subscription.recipent, + subscription.subscriber, subscription.nwc, + subscription.cadence, subscription.amount, + subscription.unit, + subscription.begin, end, + subscription.tier_dtag, subscription.zaps, recipe, + success, + Timestamp.now().as_secs(), subscription.tier) + + print("updated subscription entry") + + keys = Keys.parse(dvm_config.PRIVATE_KEY) + message = ( + "Renewed Subscription to DVM " + subscription.tier + ". Next renewal: " + str( + Timestamp.from_secs(end).to_human_datetime().replace("Z", " ").replace("T", + " "))) + evt = EventBuilder.encrypted_direct_msg(keys, PublicKey.parse(subscription.subscriber), + message, + None).to_event(keys) + send_event(evt, client=self.client, dvm_config=dvm_config) + + def check_subscriptions(): + subscriptions = get_all_subscriptions_from_sql_table(dvm_config.DB) + + for subscription in subscriptions: + if subscription.active: + if subscription.end < Timestamp.now().as_secs(): + # We could directly zap, but let's make another check if our subscription expired + subscription_status = nip88_has_active_subscription( + PublicKey.parse(subscription.subscriber), + subscription.tier_dtag, self.client, subscription.recipent) + + if subscription_status["expires"]: + # if subscription expires, just note it as not active + update_subscription_sql_table(dvm_config.DB, subscription_status["subscriptionId"], + subscription.recipent, + subscription.subscriber, subscription.nwc, + subscription.cadence, subscription.amount, + subscription.unit, + subscription.begin, subscription.end, + subscription.tier_dtag, subscription.zaps, + subscription.recipe, + False, + Timestamp.now().as_secs(), subscription.tier) + else: + handle_subscription_renewal(subscription) + + else: + handle_expired_subscription(subscription) + + print(str(Timestamp.now().as_secs()) + ": Checking " + str( + len(subscriptions)) + " Subscription entries..") + + self.client.handle_notifications(NotificationHandler()) + + try: + while True: + time.sleep(60.0) + check_subscriptions() + except KeyboardInterrupt: + print('Stay weird!') + os.kill(os.getpid(), signal.SIGTERM) diff --git a/nostr_dvm/tasks/README.md b/nostr_dvm/tasks/README.md index e57ef65..26df2a6 100644 --- a/nostr_dvm/tasks/README.md +++ b/nostr_dvm/tasks/README.md @@ -6,30 +6,32 @@ Reusable backend functions can be defined in backends (e.g. API calls) Current List of Tasks: -| Module | Kind | Description | Backend | -|------------------------------|--------|------------------------------------------------------------|------------------| -| TextExtractionPDF | 5000 | Extracts Text from a PDF file | local | -| SpeechToTextGoogle | 5000 | Extracts Speech from Media files via Google Services | googleAPI | -| SpeechToTextWhisperX | 5000 | Extracts Speech from Media files via local WhisperX | nserver | -| ImageInterrogator | 5000 | Extracts Prompts from Images | nserver | -| TextSummarizationHuggingChat | 5001 | Summarizes given Input | huggingface | -| TranslationGoogle | 5002 | Translates Inputs to another language | googleAPI | -| TranslationLibre | 5002 | Translates Inputs to another language | libreAPI | -| TextGenerationLLMLite | 5050 | Chat with LLM backends like Ollama, ChatGPT etc | local/api/openai | -| TextGenerationHuggingChat | 5050 | Chat with LLM backend on Huggingface | huggingface | -| ImageGenerationSDXL | 5100 | Generates an Image from Prompt with Stable Diffusion XL | nserver | -| ImageGenerationSDXLIMG2IMG | 5100 | Generates an Image from an Image with Stable Diffusion XL | nserver | -| ImageGenerationReplicateSDXL | 5100 | Generates an Image from Prompt with Stable Diffusion XL | replicate | -| ImageGenerationMLX | 5100 | Generates an Image with Stable Diffusion 2.1 on M1/2/3 Mac | mlx | -| ImageGenerationDALLE | 5100 | Generates an Image with OpenAI's Dall-E | openAI | -| ImageUpscale | 5100 | Upscales an Image | nserver | -| MediaConverter | 5200 | Converts a link of a media file and uploads it | openAI | -| VideoGenerationReplicateSVD | 5202 | Generates a Video from an Image | replicate | -| VideoGenerationSVD | 5202 | Generates a Video from an Image | nserver | -| TextToSpeech | 5250 | Generate Audio from a prompt | local | -| TrendingNotesNostrBand | 5300 | Show trending notes on nostr.band | nostr.band api | -| DiscoverInactiveFollows | 5301 | Find inactive Nostr users | local | -| AdvancedSearch | 5302 | Search Content on nostr.band | local/nostr.band | +| Module | Kind | Description | Backend | +|--------------------------------|--------|------------------------------------------------------------|------------------| +| TextExtractionPDF | 5000 | Extracts Text from a PDF file | local | +| SpeechToTextGoogle | 5000 | Extracts Speech from Media files via Google Services | googleAPI | +| SpeechToTextWhisperX | 5000 | Extracts Speech from Media files via local WhisperX | nserver | +| ImageInterrogator | 5000 | Extracts Prompts from Images | nserver | +| TextSummarizationHuggingChat | 5001 | Summarizes given Input | huggingface | +| TranslationGoogle | 5002 | Translates Inputs to another language | googleAPI | +| TranslationLibre | 5002 | Translates Inputs to another language | libreAPI | +| TextGenerationLLMLite | 5050 | Chat with LLM backends like Ollama, ChatGPT etc | local/api/openai | +| TextGenerationHuggingChat | 5050 | Chat with LLM backend on Huggingface | huggingface | +| TextGenerationLLMUnleashedChat | 5050 | Chat with unleashed.chat LLMs | unleashed api | +| ImageGenerationSDXL | 5100 | Generates an Image from Prompt with Stable Diffusion XL | nserver | +| ImageGenerationSDXLIMG2IMG | 5100 | Generates an Image from an Image with Stable Diffusion XL | nserver | +| ImageGenerationReplicateSDXL | 5100 | Generates an Image from Prompt with Stable Diffusion XL | replicate | +| ImageGenerationMLX | 5100 | Generates an Image with Stable Diffusion 2.1 on M1/2/3 Mac | mlx | +| ImageGenerationDALLE | 5100 | Generates an Image with OpenAI's Dall-E | openAI | +| ImageUpscale | 5100 | Upscales an Image | nserver | +| MediaConverter | 5200 | Converts a link of a media file and uploads it | openAI | +| VideoGenerationReplicateSVD | 5202 | Generates a Video from an Image | replicate | +| VideoGenerationSVD | 5202 | Generates a Video from an Image | nserver | +| TextToSpeech | 5250 | Generate Audio from a prompt | local | +| TrendingNotesNostrBand | 5300 | Show trending notes on nostr.band | nostr.band api | +| DiscoverInactiveFollows | 5301 | Find inactive Nostr users | local | +| AdvancedSearch | 5302 | Search Content on relays (nostr.band) | local/nostr.band | +| AdvancedSearchWine | 5302 | Search Content on nostr.wine | api/nostr.wine | Kinds with (inoff) are suggestions and not merged yet and might change in the future. Backends might require to add an API key to the .env file or run an external server/framework the dvm will communicate with. \ No newline at end of file diff --git a/nostr_dvm/tasks/advanced_search.py b/nostr_dvm/tasks/advanced_search.py index a2192bd..5a04963 100644 --- a/nostr_dvm/tasks/advanced_search.py +++ b/nostr_dvm/tasks/advanced_search.py @@ -1,12 +1,13 @@ import json import os from datetime import timedelta -from nostr_sdk import Client, Timestamp, PublicKey, Tag, Keys, Options, SecretKey, ClientSigner +from nostr_sdk import Client, Timestamp, PublicKey, Tag, Keys, Options, SecretKey, NostrSigner, Kind, RelayOptions from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.output_utils import post_process_list_to_events @@ -19,16 +20,16 @@ Params: None class AdvancedSearch(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_CONTENT_SEARCH + KIND: Kind = EventDefinitions.KIND_NIP90_CONTENT_SEARCH TASK: str = "search-content" FIX_COST: float = 0 dvm_config: DVMConfig - dependencies = [("nostr-dvm", "nostr-dvm")] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: @@ -48,11 +49,12 @@ class AdvancedSearch(DVMTaskInterface): # default values user = "" users = [] - since_days = 800 # days ago - until_days = 0 # days ago + since_seconds = Timestamp.now().as_secs() - (365 * 24 * 60 * 60) + until_seconds = Timestamp.now().as_secs() search = "" - max_results = 20 - + max_results = 100 + relay = "wss://relay.nostr.band" + for tag in event.tags(): if tag.as_vec()[0] == 'i': input_type = tag.as_vec()[2] @@ -61,23 +63,24 @@ class AdvancedSearch(DVMTaskInterface): elif tag.as_vec()[0] == 'param': param = tag.as_vec()[1] if param == "user": # check for param type - user = tag.as_vec()[2] + #user = tag.as_vec()[2] + users.append(Tag.parse(["p", tag.as_vec()[2]])) elif param == "users": # check for param type users = json.loads(tag.as_vec()[2]) elif param == "since": # check for param type - since_days = int(tag.as_vec()[2]) + since_seconds = int(tag.as_vec()[2]) elif param == "until": # check for param type - until_days = int(tag.as_vec()[2]) + until_seconds = min(int(tag.as_vec()[2]), until_seconds) elif param == "max_results": # check for param type max_results = int(tag.as_vec()[2]) options = { "search": search, - "user": user, "users": users, - "since": since_days, - "until": until_days, - "max_results": max_results + "since": since_seconds, + "until": until_seconds, + "max_results": max_results, + "relay": relay } request_form['options'] = json.dumps(options) return request_form @@ -88,23 +91,29 @@ class AdvancedSearch(DVMTaskInterface): opts = (Options().wait_for_send(False).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT))) sk = SecretKey.from_hex(self.dvm_config.PRIVATE_KEY) - keys = Keys.from_sk_str(sk.to_hex()) - signer = ClientSigner.keys(keys) + keys = Keys.parse(sk.to_hex()) + signer = NostrSigner.keys(keys) cli = Client.with_opts(signer, opts) - cli.add_relay("wss://relay.nostr.band") + ropts = RelayOptions().ping(False) + cli.add_relay_with_opts(options["relay"], ropts) + cli.connect() - search_since_seconds = int(options["since"]) * 24 * 60 * 60 - dif = Timestamp.now().as_secs() - search_since_seconds - search_since = Timestamp.from_secs(dif) + #earch_since_seconds = int(options["since"]) * 24 * 60 * 60 + #dif = Timestamp.now().as_secs() - search_since_seconds + #search_since = Timestamp.from_secs(dif) + search_since = Timestamp.from_secs(int(options["since"])) - search_until_seconds = int(options["until"]) * 24 * 60 * 60 - dif = Timestamp.now().as_secs() - search_until_seconds - search_until = Timestamp.from_secs(dif) + #search_until_seconds = int(options["until"]) * 24 * 60 * 60 + #dif = Timestamp.now().as_secs() - search_until_seconds + #search_until = Timestamp.from_secs(dif) + search_until = Timestamp.from_secs(int(options["until"])) userkeys = [] for user in options["users"]: - user = user[1] + tag = Tag.parse(user) + user = tag.as_vec()[1] + #user = user[1] user = str(user).lstrip("@") if str(user).startswith('npub'): userkey = PublicKey.from_bech32(user) @@ -115,22 +124,13 @@ class AdvancedSearch(DVMTaskInterface): userkeys.append(userkey) - if not options["users"] and options["user"] == "": - notes_filter = Filter().kind(1).search(options["search"]).since(search_since).until(search_until).limit( - options["max_results"]) + if not options["users"]: + notes_filter = Filter().kind(Kind(1)).search(options["search"]).since(search_since).until(search_until).limit(options["max_results"]) elif options["search"] == "": - if options["users"]: - notes_filter = Filter().kind(1).authors(userkeys).since(search_since).until( - search_until).limit(options["max_results"]) - else: - notes_filter = Filter().kind(1).authors([PublicKey.from_hex(options["user"])]).since(search_since).until( + notes_filter = Filter().kind(Kind(1)).authors(userkeys).since(search_since).until( search_until).limit(options["max_results"]) else: - if options["users"]: - notes_filter = Filter().kind(1).authors(userkeys).search(options["search"]).since( - search_since).until(search_until).limit(options["max_results"]) - else: - notes_filter = Filter().kind(1).authors([PublicKey.from_hex(options["user"])]).search(options["search"]).since( + notes_filter = Filter().kind(Kind(1)).authors(userkeys).search(options["search"]).since( search_since).until(search_until).limit(options["max_results"]) @@ -141,7 +141,7 @@ class AdvancedSearch(DVMTaskInterface): for event in events: e_tag = Tag.parse(["e", event.id().to_hex()]) - print(e_tag.as_vec()) + #print(e_tag.as_vec()) result_list.append(e_tag.as_vec()) return json.dumps(result_list) @@ -163,29 +163,29 @@ class AdvancedSearch(DVMTaskInterface): # playground or elsewhere def build_example(name, identifier, admin_config): dvm_config = build_default_config(identifier) - admin_config.LUD16 = dvm_config.LN_ADDRESS + dvm_config.USE_OWN_VENV = False # Add NIP89 nip89info = { "name": name, - "image": "https://image.nostr.build/c33ca6fc4cc038ca4adb46fdfdfda34951656f87ee364ef59095bae1495ce669.jpg", - "about": "I search notes", + "image": "https://nostr.band/android-chrome-192x192.png", + "about": "I search notes on Nostr.band.", "encryptionSupported": True, "cashuAccepted": True, "nip90Params": { - "user": { + "users": { "required": False, "values": [], - "description": "Do the task for another user" + "description": "Search for content from specific users" }, "since": { "required": False, "values": [], - "description": "The number of days in the past from now the search should include" + "description": "A unix timestamp in the past from where the search should start" }, "until": { "required": False, "values": [], - "description": "The number of days in the past from now the search should include up to" + "description": "A unix timestamp that tells until the search should include results" }, "max_results": { "required": False, @@ -199,8 +199,10 @@ def build_example(name, identifier, admin_config): nip89config.DTAG = check_and_set_d_tag(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) nip89config.CONTENT = json.dumps(nip89info) + options = {"relay": "wss://relay.nostr.band"} + return AdvancedSearch(name=name, dvm_config=dvm_config, nip89config=nip89config, - admin_config=admin_config) + admin_config=admin_config, options=options) if __name__ == '__main__': diff --git a/nostr_dvm/tasks/advanced_search_wine.py b/nostr_dvm/tasks/advanced_search_wine.py index 302b561..076647e 100644 --- a/nostr_dvm/tasks/advanced_search_wine.py +++ b/nostr_dvm/tasks/advanced_search_wine.py @@ -3,12 +3,13 @@ import os from datetime import timedelta import requests -from nostr_sdk import Client, Timestamp, PublicKey, Tag, Keys, Options, SecretKey, ClientSigner, Event +from nostr_sdk import Client, Timestamp, PublicKey, Tag, Keys, Options, SecretKey, NostrSigner, Event, Kind from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.output_utils import post_process_list_to_events @@ -21,16 +22,16 @@ Params: None class AdvancedSearchWine(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_CONTENT_SEARCH + KIND: Kind = EventDefinitions.KIND_NIP90_CONTENT_SEARCH TASK: str = "search-content" FIX_COST: float = 0 dvm_config: DVMConfig - dependencies = [("nostr-dvm", "nostr-dvm")] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: @@ -50,8 +51,8 @@ class AdvancedSearchWine(DVMTaskInterface): # default values user = "" users = [] - since_days = 800 # days ago - until_days = 365 # days ago + since_seconds = Timestamp.now().as_secs() - (365 * 24 * 60 * 60) + until_seconds = Timestamp.now().as_secs() search = "" max_results = 100 @@ -68,9 +69,9 @@ class AdvancedSearchWine(DVMTaskInterface): elif param == "users": # check for param type users = json.loads(tag.as_vec()[2]) elif param == "since": # check for param type - since_days = int(tag.as_vec()[2]) + since_seconds = int(tag.as_vec()[2]) elif param == "until": # check for param type - until_days = int(tag.as_vec()[2]) + until_seconds = int(tag.as_vec()[2]) elif param == "max_results": # check for param type max_results = int(tag.as_vec()[2]) @@ -78,8 +79,8 @@ class AdvancedSearchWine(DVMTaskInterface): "search": search, "user": user, "users": users, - "since": since_days, - "until": until_days, + "since": since_seconds, + "until": until_seconds, "max_results": max_results, } @@ -91,7 +92,8 @@ class AdvancedSearchWine(DVMTaskInterface): options = DVMTaskInterface.set_options(request_form) userkeys = [] for user in options["users"]: - user = user[1] + tag = Tag.parse(user) + user = tag.as_vec()[1] user = str(user).lstrip("@") if str(user).startswith('npub'): userkey = PublicKey.from_bech32(user) @@ -102,23 +104,27 @@ class AdvancedSearchWine(DVMTaskInterface): userkeys.append(userkey) - print("Sending job to Server") + print("Sending job to nostr.wine API") if options["users"]: - url = ('https://api.nostr.wine/search?query=' + options["search"] + '&kind=1' + '&pubkey=' + options["users"][0] + "&limit=100" + "&sort=time") + url = ('https://api.nostr.wine/search?query=' + options["search"] + '&kind=1' + '&pubkey=' + options["users"][0][1] + "&limit=100" + "&sort=time" + "&until=" + str(options["until"]) + "&since=" + str(options["since"])) else: - url = ('https://api.nostr.wine/search?query=' + options["search"] + '&kind=1' + "&limit=100" + "&sort=time") + url = ('https://api.nostr.wine/search?query=' + options["search"] + '&kind=1' + "&limit=100" + "&sort=time" + "&until=" + str(options["until"]) + "&since=" + str(options["since"])) headers = {'Content-type': 'application/x-www-form-urlencoded'} response = requests.get(url, headers=headers) - print(response.text) - ob = json.loads(response.text) - data = ob['data'] - print(data) + #print(response.text) result_list = [] - for el in data: - e_tag = Tag.parse(["e", el['id']]) - print(e_tag.as_vec()) - result_list.append(e_tag.as_vec()) + try: + ob = json.loads(response.text) + data = ob['data'] + for el in data: + try: + e_tag = Tag.parse(["e", el['id']]) + result_list.append(e_tag.as_vec()) + except Exception as e: + print("ERROR: " + str(e)) + except Exception as e: + print(e) return json.dumps(result_list) @@ -140,6 +146,7 @@ class AdvancedSearchWine(DVMTaskInterface): # playground or elsewhere def build_example(name, identifier, admin_config): dvm_config = build_default_config(identifier) + dvm_config.USE_OWN_VENV = False # Add NIP89 nip89info = { "name": name, diff --git a/nostr_dvm/tasks/content_discovery_currently_popular.py b/nostr_dvm/tasks/content_discovery_currently_popular.py new file mode 100644 index 0000000..af9b925 --- /dev/null +++ b/nostr_dvm/tasks/content_discovery_currently_popular.py @@ -0,0 +1,266 @@ +import json +import os +from datetime import timedelta +from nostr_sdk import Client, Timestamp, PublicKey, Tag, Keys, Options, SecretKey, NostrSigner, NostrDatabase, \ + ClientBuilder, Filter, NegentropyOptions, NegentropyDirection, init_logger, LogLevel, Event, EventId, Kind + +from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv +from nostr_dvm.utils import definitions +from nostr_dvm.utils.admin_utils import AdminConfig +from nostr_dvm.utils.definitions import EventDefinitions +from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config, check_and_set_d_tag_nip88, check_and_set_tiereventid_nip88 +from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag +from nostr_dvm.utils.output_utils import post_process_list_to_events, post_process_list_to_users + +""" +This File contains a Module to discover popular notes +Accepted Inputs: none +Outputs: A list of events +Params: None +""" + + +class DicoverContentCurrentlyPopular(DVMTaskInterface): + KIND: Kind = EventDefinitions.KIND_NIP90_CONTENT_DISCOVERY + TASK: str = "discover-content" + FIX_COST: float = 0 + dvm_config: DVMConfig + last_schedule: int + + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, + admin_config: AdminConfig = None, options=None): + dvm_config.SCRIPT = os.path.abspath(__file__) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) + + self.last_schedule = Timestamp.now().as_secs() + + use_logger = False + if use_logger: + init_logger(LogLevel.DEBUG) + + self.sync_db() + + def is_input_supported(self, tags, client=None, dvm_config=None): + for tag in tags: + if tag.as_vec()[0] == 'i': + input_value = tag.as_vec()[1] + input_type = tag.as_vec()[2] + if input_type != "text": + return False + return True + + def create_request_from_nostr_event(self, event, client=None, dvm_config=None): + self.dvm_config = dvm_config + print(self.dvm_config.PRIVATE_KEY) + + request_form = {"jobID": event.id().to_hex()} + + # default values + search = "" + max_results = 100 + + for tag in event.tags(): + if tag.as_vec()[0] == 'i': + input_type = tag.as_vec()[2] + elif tag.as_vec()[0] == 'param': + param = tag.as_vec()[1] + if param == "max_results": # check for param type + max_results = int(tag.as_vec()[2]) + + options = { + "max_results": max_results, + } + request_form['options'] = json.dumps(options) + return request_form + + def process(self, request_form): + from nostr_sdk import Filter + from types import SimpleNamespace + ns = SimpleNamespace() + + options = DVMTaskInterface.set_options(request_form) + + opts = (Options().wait_for_send(False).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT))) + sk = SecretKey.from_hex(self.dvm_config.PRIVATE_KEY) + keys = Keys.parse(sk.to_hex()) + signer = NostrSigner.keys(keys) + + database = NostrDatabase.sqlite("db/nostr_recent_notes.db") + cli = ClientBuilder().database(database).signer(signer).opts(opts).build() + + cli.add_relay("wss://relay.damus.io") + cli.connect() + + # Negentropy reconciliation + # Query events from database + timestamp_hour_ago = Timestamp.now().as_secs() - 3600 + lasthour = Timestamp.from_secs(timestamp_hour_ago) + + filter1 = Filter().kind(definitions.EventDefinitions.KIND_NOTE).since(lasthour) + events = cli.database().query([filter1]) + ns.finallist = {} + for event in events: + if event.created_at().as_secs() > timestamp_hour_ago: + ns.finallist[event.id().to_hex()] = 0 + filt = Filter().kinds([definitions.EventDefinitions.KIND_ZAP, definitions.EventDefinitions.KIND_REPOST, definitions.EventDefinitions.KIND_REACTION, definitions.EventDefinitions.KIND_NOTE]).event(event.id()).since(lasthour) + reactions = cli.database().query([filt]) + ns.finallist[event.id().to_hex()] = len(reactions) + + result_list = [] + finallist_sorted = sorted(ns.finallist.items(), key=lambda x: x[1], reverse=True)[:int(options["max_results"])] + for entry in finallist_sorted: + #print(EventId.parse(entry[0]).to_bech32() + "/" + EventId.parse(entry[0]).to_hex() + ": " + str(entry[1])) + e_tag = Tag.parse(["e", entry[0]]) + result_list.append(e_tag.as_vec()) + + return json.dumps(result_list) + + def post_process(self, result, event): + """Overwrite the interface function to return a social client readable format, if requested""" + for tag in event.tags(): + if tag.as_vec()[0] == 'output': + format = tag.as_vec()[1] + if format == "text/plain": # check for output type + result = post_process_list_to_users(result) + + # if not text/plain, don't post-process + return result + + def schedule(self, dvm_config): + if dvm_config.SCHEDULE_UPDATES_SECONDS == 0: + return 0 + else: + if Timestamp.now().as_secs() >= self.last_schedule + dvm_config.SCHEDULE_UPDATES_SECONDS: + self.sync_db() + self.last_schedule = Timestamp.now().as_secs() + return 1 + + def sync_db(self): + opts = (Options().wait_for_send(False).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT))) + sk = SecretKey.from_hex(self.dvm_config.PRIVATE_KEY) + keys = Keys.parse(sk.to_hex()) + signer = NostrSigner.keys(keys) + database = NostrDatabase.sqlite("db/nostr_recent_notes.db") + cli = ClientBuilder().signer(signer).database(database).opts(opts).build() + + cli.add_relay("wss://relay.damus.io") + cli.connect() + + timestamp_hour_ago = Timestamp.now().as_secs() - 3600 + lasthour = Timestamp.from_secs(timestamp_hour_ago) + + filter1 = Filter().kinds([definitions.EventDefinitions.KIND_NOTE, definitions.EventDefinitions.KIND_REACTION, definitions.EventDefinitions.KIND_ZAP]).since(lasthour) # Notes, reactions, zaps + + # filter = Filter().author(keys.public_key()) + print("Syncing Notes of last hour.. this might take a while..") + dbopts = NegentropyOptions().direction(NegentropyDirection.DOWN) + cli.reconcile(filter1, dbopts) + database.delete(Filter().until(Timestamp.from_secs( + Timestamp.now().as_secs() - 3600))) # Clear old events so db doesnt get too full. + + print("Done Syncing Notes of Last hour.") + + +# We build an example here that we can call by either calling this file directly from the main directory, +# or by adding it to our playground. You can call the example and adjust it to your needs or redefine it in the +# playground or elsewhere +def build_example(name, identifier, admin_config): + dvm_config = build_default_config(identifier) + dvm_config.USE_OWN_VENV = False + dvm_config.SHOWLOG = True + dvm_config.SCHEDULE_UPDATES_SECONDS = 600 # Every 10 minutes + # Activate these to use a subscription based model instead + # dvm_config.SUBSCRIPTION_REQUIRED = True + # dvm_config.SUBSCRIPTION_DAILY_COST = 1 + dvm_config.FIX_COST = 0 + + # Add NIP89 + nip89info = { + "name": name, + "image": "https://image.nostr.build/b29b6ec4bf9b6184f69d33cb44862db0d90a2dd9a506532e7ba5698af7d36210.jpg", + "about": "I show notes that are currently popular", + "lud16": dvm_config.LN_ADDRESS, + "encryptionSupported": True, + "cashuAccepted": True, + "amount": "free", + "nip90Params": { + "max_results": { + "required": False, + "values": [], + "description": "The number of maximum results to return (default currently 100)" + } + } + } + + nip89config = NIP89Config() + nip89config.DTAG = check_and_set_d_tag(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) + nip89config.CONTENT = json.dumps(nip89info) + + admin_config.UPDATE_PROFILE = False + admin_config.REBROADCAST_NIP89 = False + + return DicoverContentCurrentlyPopular(name=name, dvm_config=dvm_config, nip89config=nip89config, + admin_config=admin_config) + + +def build_example_subscription(name, identifier, admin_config): + dvm_config = build_default_config(identifier) + dvm_config.USE_OWN_VENV = False + dvm_config.SHOWLOG = True + dvm_config.SCHEDULE_UPDATES_SECONDS = 600 # Every 10 minutes + # Activate these to use a subscription based model instead + # dvm_config.SUBSCRIPTION_DAILY_COST = 1 + dvm_config.FIX_COST = 0 + + # Add NIP89 + nip89info = { + "name": name, + "image": "https://image.nostr.build/b29b6ec4bf9b6184f69d33cb44862db0d90a2dd9a506532e7ba5698af7d36210.jpg", + "about": "I show notes that are currently popular all over Nostr. I'm also used for testing subscriptions.", + "lud16": dvm_config.LN_ADDRESS, + "encryptionSupported": True, + "cashuAccepted": True, + "subscription": True, + "nip90Params": { + "max_results": { + "required": False, + "values": [], + "description": "The number of maximum results to return (default currently 100)" + } + } + } + + nip89config = NIP89Config() + nip89config.DTAG = check_and_set_d_tag(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) + nip89config.CONTENT = json.dumps(nip89info) + + nip88config = NIP88Config() + nip88config.DTAG = check_and_set_d_tag_nip88(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) + nip88config.TIER_EVENT = check_and_set_tiereventid_nip88(identifier, "1") + nip89config.NAME = name + nip88config.IMAGE = nip89info["image"] + nip88config.TITLE = name + nip88config.AMOUNT_DAILY = 100 + nip88config.AMOUNT_MONTHLY = 2000 + nip88config.CONTENT = "Subscribe to the DVM for unlimited use during your subscription" + nip88config.PERK1DESC = "Unlimited requests" + nip88config.PERK2DESC = "Support NostrDVM & NostrSDK development" + nip88config.PAYMENT_VERIFIER_PUBKEY = "5b5c045ecdf66fb540bdf2049fe0ef7f1a566fa427a4fe50d400a011b65a3a7e" + + admin_config.UPDATE_PROFILE = False + admin_config.REBROADCAST_NIP89 = False + admin_config.REBROADCAST_NIP88 = False + + # admin_config.FETCH_NIP88 = True + # admin_config.EVENTID = "63a791cdc7bf78c14031616963105fce5793f532bb231687665b14fb6d805fdb" + # admin_config.PRIVKEY = dvm_config.PRIVATE_KEY + + return DicoverContentCurrentlyPopular(name=name, dvm_config=dvm_config, nip89config=nip89config, + nip88config=nip88config, + admin_config=admin_config) + + +if __name__ == '__main__': + process_venv(DicoverContentCurrentlyPopular) diff --git a/nostr_dvm/tasks/content_discovery_currently_popular_followers.py b/nostr_dvm/tasks/content_discovery_currently_popular_followers.py new file mode 100644 index 0000000..9ee236e --- /dev/null +++ b/nostr_dvm/tasks/content_discovery_currently_popular_followers.py @@ -0,0 +1,307 @@ +import json +import os +from datetime import timedelta +from nostr_sdk import Client, Timestamp, PublicKey, Tag, Keys, Options, SecretKey, NostrSigner, NostrDatabase, \ + ClientBuilder, Filter, NegentropyOptions, NegentropyDirection, init_logger, LogLevel, Event, EventId, Kind, \ + RelayOptions, RelayLimits + +from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv +from nostr_dvm.utils import definitions +from nostr_dvm.utils.admin_utils import AdminConfig +from nostr_dvm.utils.definitions import EventDefinitions +from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config, check_and_set_d_tag_nip88, check_and_set_tiereventid_nip88 +from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag +from nostr_dvm.utils.output_utils import post_process_list_to_events, post_process_list_to_users + +""" +This File contains a Module to discover popular notes +Accepted Inputs: none +Outputs: A list of events +Params: None +""" + + +class DicoverContentCurrentlyPopularFollowers(DVMTaskInterface): + KIND: Kind = EventDefinitions.KIND_NIP90_CONTENT_DISCOVERY + TASK: str = "discover-content" + FIX_COST: float = 0 + dvm_config: DVMConfig + last_schedule: int + + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, + admin_config: AdminConfig = None, options=None): + dvm_config.SCRIPT = os.path.abspath(__file__) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) + + self.last_schedule = Timestamp.now().as_secs() + + use_logger = False + if use_logger: + init_logger(LogLevel.DEBUG) + + self.sync_db() + + def is_input_supported(self, tags, client=None, dvm_config=None): + for tag in tags: + if tag.as_vec()[0] == 'i': + input_value = tag.as_vec()[1] + input_type = tag.as_vec()[2] + if input_type != "text": + return False + return True + + def create_request_from_nostr_event(self, event: Event, client=None, dvm_config=None): + self.dvm_config = dvm_config + print(self.dvm_config.PRIVATE_KEY) + + request_form = {"jobID": event.id().to_hex()} + + # default values + user = event.author().to_hex() + max_results = 100 + + + for tag in event.tags(): + if tag.as_vec()[0] == 'i': + input_type = tag.as_vec()[2] + elif tag.as_vec()[0] == 'param': + param = tag.as_vec()[1] + if param == "max_results": # check for param type + max_results = int(tag.as_vec()[2]) + elif param == "user": # check for param type + user = tag.as_vec()[2] + + + + options = { + "max_results": max_results, + "user": user, + } + request_form['options'] = json.dumps(options) + return request_form + + def process(self, request_form): + from nostr_sdk import Filter + from types import SimpleNamespace + ns = SimpleNamespace() + + options = DVMTaskInterface.set_options(request_form) + relaylimits = RelayLimits.disable() + opts = (Options().wait_for_send(True).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT)).relay_limits(relaylimits)) + sk = SecretKey.from_hex(self.dvm_config.PRIVATE_KEY) + keys = Keys.parse(sk.to_hex()) + signer = NostrSigner.keys(keys) + + database = NostrDatabase.sqlite("db/nostr_recent_notes2.db") + cli = ClientBuilder().database(database).signer(signer).opts(opts).build() + cli.add_relay("wss://relay.damus.io") + cli.add_relay("wss://nos.lol") + cli.add_relay("wss://pablof7z.nostr1.com") + + ropts = RelayOptions().ping(False) + cli.add_relay_with_opts("wss://nostr.band", ropts) + + cli.connect() + + user = PublicKey.parse(options["user"]) + followers_filter = Filter().author(user).kinds([Kind(3)]) + followers = cli.get_events_of([followers_filter], timedelta(seconds=self.dvm_config.RELAY_TIMEOUT)) + print(followers) + + # Negentropy reconciliation + # Query events from database + timestamp_hour_ago = Timestamp.now().as_secs() - 7200 + lasthour = Timestamp.from_secs(timestamp_hour_ago) + + + result_list = [] + + if len(followers) > 0: + newest = 0 + best_entry = followers[0] + for entry in followers: + if entry.created_at().as_secs() > newest: + newest = entry.created_at().as_secs() + best_entry = entry + + print(best_entry.as_json()) + followings = [] + for tag in best_entry.tags(): + if tag.as_vec()[0] == "p": + following = PublicKey.parse(tag.as_vec()[1]) + followings.append(following) + + filter1 = Filter().kind(definitions.EventDefinitions.KIND_NOTE).authors(followings).since(lasthour) + events = cli.database().query([filter1]) + + ns.finallist = {} + for event in events: + if event.created_at().as_secs() > timestamp_hour_ago: + ns.finallist[event.id().to_hex()] = 0 + filt = Filter().kinds( + [definitions.EventDefinitions.KIND_ZAP, definitions.EventDefinitions.KIND_REACTION, definitions.EventDefinitions.KIND_REPOST, + definitions.EventDefinitions.KIND_NOTE]).event(event.id()).since(lasthour) + reactions = cli.database().query([filt]) + ns.finallist[event.id().to_hex()] = len(reactions) + + + finallist_sorted = sorted(ns.finallist.items(), key=lambda x: x[1], reverse=True)[:int(options["max_results"])] + for entry in finallist_sorted: + # print(EventId.parse(entry[0]).to_bech32() + "/" + EventId.parse(entry[0]).to_hex() + ": " + str(entry[1])) + e_tag = Tag.parse(["e", entry[0]]) + result_list.append(e_tag.as_vec()) + + return json.dumps(result_list) + + def post_process(self, result, event): + """Overwrite the interface function to return a social client readable format, if requested""" + for tag in event.tags(): + if tag.as_vec()[0] == 'output': + format = tag.as_vec()[1] + if format == "text/plain": # check for output type + result = post_process_list_to_users(result) + + # if not text/plain, don't post-process + return result + + def schedule(self, dvm_config): + if dvm_config.SCHEDULE_UPDATES_SECONDS == 0: + return 0 + # We simply use the db from the other dvm that contains all notes + + else: + if Timestamp.now().as_secs() >= self.last_schedule + dvm_config.SCHEDULE_UPDATES_SECONDS: + self.sync_db() + self.last_schedule = Timestamp.now().as_secs() + return 1 + + def sync_db(self): + opts = (Options().wait_for_send(False).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT))) + sk = SecretKey.from_hex(self.dvm_config.PRIVATE_KEY) + keys = Keys.parse(sk.to_hex()) + signer = NostrSigner.keys(keys) + database = NostrDatabase.sqlite("db/nostr_recent_notes2.db") + cli = ClientBuilder().signer(signer).database(database).opts(opts).build() + + cli.add_relay("wss://relay.damus.io") + cli.connect() + + timestamp_hour_ago = Timestamp.now().as_secs() - 7200 + lasthour = Timestamp.from_secs(timestamp_hour_ago) + + filter1 = Filter().kinds([definitions.EventDefinitions.KIND_NOTE, definitions.EventDefinitions.KIND_REACTION, + definitions.EventDefinitions.KIND_ZAP]).since(lasthour) # Notes, reactions, zaps + + # filter = Filter().author(keys.public_key()) + print("Syncing Notes.. this might take a while..") + dbopts = NegentropyOptions().direction(NegentropyDirection.DOWN) + cli.reconcile(filter1, dbopts) + database.delete(Filter().until(Timestamp.from_secs( + Timestamp.now().as_secs() - 7200))) # Clear old events so db doesnt get too full. + + print("Done Syncing Notes of Last hour.") + + +# We build an example here that we can call by either calling this file directly from the main directory, +# or by adding it to our playground. You can call the example and adjust it to your needs or redefine it in the +# playground or elsewhere +def build_example(name, identifier, admin_config): + dvm_config = build_default_config(identifier) + dvm_config.USE_OWN_VENV = False + dvm_config.SHOWLOG = True + dvm_config.SCHEDULE_UPDATES_SECONDS = 600 # Every 10 minutes + # Activate these to use a subscription based model instead + # dvm_config.SUBSCRIPTION_REQUIRED = True + # dvm_config.SUBSCRIPTION_DAILY_COST = 1 + dvm_config.FIX_COST = 0 + + # Add NIP89 + nip89info = { + "name": name, + "image": "https://image.nostr.build/d92652a6a07677e051d647dcf9f0f59e265299b3335a939d008183a911513f4a.jpg", + "about": "I show notes that are currently popular from people you follow", + "lud16": dvm_config.LN_ADDRESS, + "encryptionSupported": True, + "cashuAccepted": True, + "amount": "free", + "nip90Params": { + "max_results": { + "required": False, + "values": [], + "description": "The number of maximum results to return (default currently 100)" + } + } + } + + nip89config = NIP89Config() + nip89config.DTAG = check_and_set_d_tag(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) + nip89config.CONTENT = json.dumps(nip89info) + + admin_config.UPDATE_PROFILE = False + admin_config.REBROADCAST_NIP89 = False + + return DicoverContentCurrentlyPopularFollowers(name=name, dvm_config=dvm_config, nip89config=nip89config, + admin_config=admin_config) + + +def build_example_subscription(name, identifier, admin_config): + dvm_config = build_default_config(identifier) + dvm_config.USE_OWN_VENV = False + dvm_config.SHOWLOG = True + dvm_config.SCHEDULE_UPDATES_SECONDS = 600 # Every 10 minutes + # Activate these to use a subscription based model instead + # dvm_config.SUBSCRIPTION_DAILY_COST = 1 + dvm_config.FIX_COST = 0 + + # Add NIP89 + nip89info = { + "name": name, + "image": "https://image.nostr.build/b29b6ec4bf9b6184f69d33cb44862db0d90a2dd9a506532e7ba5698af7d36210.jpg", + "about": "I show notes that are currently popular, just like the free DVM, I'm also used for testing subscriptions. (beta)", + "lud16": dvm_config.LN_ADDRESS, + "encryptionSupported": True, + "cashuAccepted": True, + "subscription": True, + "nip90Params": { + "max_results": { + "required": False, + "values": [], + "description": "The number of maximum results to return (default currently 100)" + } + } + } + + nip89config = NIP89Config() + nip89config.DTAG = check_and_set_d_tag(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) + nip89config.CONTENT = json.dumps(nip89info) + + nip88config = NIP88Config() + nip88config.DTAG = check_and_set_d_tag_nip88(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) + nip88config.TIER_EVENT = check_and_set_tiereventid_nip88(identifier, "1") + nip89config.NAME = name + nip88config.IMAGE = nip89info["image"] + nip88config.TITLE = name + nip88config.AMOUNT_DAILY = 100 + nip88config.AMOUNT_MONTHLY = 2000 + nip88config.CONTENT = "Subscribe to the DVM for unlimited use during your subscription" + nip88config.PERK1DESC = "Unlimited requests" + nip88config.PERK2DESC = "Support NostrDVM & NostrSDK development" + nip88config.PAYMENT_VERIFIER_PUBKEY = "5b5c045ecdf66fb540bdf2049fe0ef7f1a566fa427a4fe50d400a011b65a3a7e" + + admin_config.UPDATE_PROFILE = False + admin_config.REBROADCAST_NIP89 = False + admin_config.REBROADCAST_NIP88 = False + + # admin_config.FETCH_NIP88 = True + # admin_config.EVENTID = "63a791cdc7bf78c14031616963105fce5793f532bb231687665b14fb6d805fdb" + # admin_config.PRIVKEY = dvm_config.PRIVATE_KEY + + return DicoverContentCurrentlyPopularFollowers(name=name, dvm_config=dvm_config, nip89config=nip89config, + nip88config=nip88config, + admin_config=admin_config) + + +if __name__ == '__main__': + process_venv(DicoverContentCurrentlyPopularFollowers) diff --git a/nostr_dvm/tasks/convert_media.py b/nostr_dvm/tasks/convert_media.py index c6094a0..9671756 100644 --- a/nostr_dvm/tasks/convert_media.py +++ b/nostr_dvm/tasks/convert_media.py @@ -1,9 +1,13 @@ import json import os + +from nostr_sdk import Kind + from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config from nostr_dvm.utils.mediasource_utils import organize_input_media_data from nostr_dvm.utils.output_utils import upload_media_to_hoster @@ -18,15 +22,16 @@ Params: -language The target language class MediaConverter(DVMTaskInterface): - KIND = EventDefinitions.KIND_NIP90_CONVERT_VIDEO + KIND: Kind = EventDefinitions.KIND_NIP90_CONVERT_VIDEO TASK = "convert" FIX_COST = 20 PER_UNIT_COST = 0.1 - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: @@ -79,6 +84,7 @@ class MediaConverter(DVMTaskInterface): # playground or elsewhere def build_example(name, identifier, admin_config): dvm_config = build_default_config(identifier) + dvm_config.USE_OWN_VENV = False admin_config.LUD16 = dvm_config.LN_ADDRESS # Add NIP89 nip89info = { diff --git a/nostr_dvm/tasks/discovery_bot_farms.py b/nostr_dvm/tasks/discovery_bot_farms.py new file mode 100644 index 0000000..4f638c8 --- /dev/null +++ b/nostr_dvm/tasks/discovery_bot_farms.py @@ -0,0 +1,203 @@ +import json +import os +from datetime import timedelta +from nostr_sdk import Client, Timestamp, PublicKey, Tag, Keys, Options, SecretKey, NostrSigner, NostrDatabase, \ + ClientBuilder, Filter, NegentropyOptions, NegentropyDirection, init_logger, LogLevel, Kind + +from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv +from nostr_dvm.utils.admin_utils import AdminConfig +from nostr_dvm.utils.definitions import EventDefinitions +from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config +from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag +from nostr_dvm.utils.output_utils import post_process_list_to_events, post_process_list_to_users + +""" +This File contains a Module to search for notes +Accepted Inputs: a search query +Outputs: A list of events +Params: None +""" + + +class DiscoveryBotFarms(DVMTaskInterface): + KIND: Kind = EventDefinitions.KIND_NIP90_PEOPLE_DISCOVERY + TASK: str = "discover-bot-farms" + FIX_COST: float = 0 + dvm_config: DVMConfig + last_schedule: int = 0 + + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, + admin_config: AdminConfig = None, options=None): + dvm_config.SCRIPT = os.path.abspath(__file__) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) + + use_logger = False + if use_logger: + init_logger(LogLevel.DEBUG) + + self.sync_db() + + def is_input_supported(self, tags, client=None, dvm_config=None): + for tag in tags: + if tag.as_vec()[0] == 'i': + input_value = tag.as_vec()[1] + input_type = tag.as_vec()[2] + if input_type != "text": + return False + return True + + def create_request_from_nostr_event(self, event, client=None, dvm_config=None): + self.dvm_config = dvm_config + print(self.dvm_config.PRIVATE_KEY) + + request_form = {"jobID": event.id().to_hex()} + + # default values + search = "@nostrich.house" + max_results = 500 + + for tag in event.tags(): + if tag.as_vec()[0] == 'i': + input_type = tag.as_vec()[2] + if input_type == "text": + search = tag.as_vec()[1] + elif tag.as_vec()[0] == 'param': + param = tag.as_vec()[1] + if param == "max_results": # check for param type + max_results = int(tag.as_vec()[2]) + + options = { + "search": search, + "max_results": max_results, + } + request_form['options'] = json.dumps(options) + return request_form + + def process(self, request_form): + from nostr_sdk import Filter + options = DVMTaskInterface.set_options(request_form) + + opts = (Options().wait_for_send(False).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT))) + sk = SecretKey.from_hex(self.dvm_config.PRIVATE_KEY) + keys = Keys.parse(sk.to_hex()) + signer = NostrSigner.keys(keys) + + database = NostrDatabase.sqlite("db/nostr_profiles.db") + cli = ClientBuilder().database(database).signer(signer).opts(opts).build() + + cli.add_relay("wss://relay.damus.io") + # cli.add_relay("wss://atl.purplerelay.com") + cli.connect() + + # Negentropy reconciliation + + # Query events from database + + filter1 = Filter().kind(Kind(0)) + events = cli.database().query([filter1]) + # for event in events: + # print(event.as_json()) + + # events = cli.get_events_of([notes_filter], timedelta(seconds=5)) + + result_list = [] + print("Events: " + str(len(events))) + + searchterms = str(options["search"]).split(";") + index = 0 + if len(events) > 0: + + for event in events: + if index < options["max_results"]: + try: + if any(ext in event.content().lower() for ext in searchterms): + p_tag = Tag.parse(["p", event.author().to_hex()]) + print(event.as_json()) + result_list.append(p_tag.as_vec()) + index += 1 + except Exception as exp: + print(str(exp) + " " + event.author().to_hex()) + else: + break + + return json.dumps(result_list) + + def post_process(self, result, event): + """Overwrite the interface function to return a social client readable format, if requested""" + for tag in event.tags(): + if tag.as_vec()[0] == 'output': + format = tag.as_vec()[1] + if format == "text/plain": # check for output type + result = post_process_list_to_users(result) + + # if not text/plain, don't post-process + return result + + def schedule(self, dvm_config): + if dvm_config.SCHEDULE_UPDATES_SECONDS == 0: + return 0 + else: + if Timestamp.now().as_secs() >= self.last_schedule + dvm_config.SCHEDULE_UPDATES_SECONDS: + self.sync_db() + self.last_schedule = Timestamp.now().as_secs() + return 1 + + def sync_db(self): + opts = (Options().wait_for_send(False).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT))) + sk = SecretKey.from_hex(self.dvm_config.PRIVATE_KEY) + keys = Keys.parse(sk.to_hex()) + signer = NostrSigner.keys(keys) + database = NostrDatabase.sqlite("db/nostr_profiles.db") + cli = ClientBuilder().signer(signer).database(database).opts(opts).build() + + cli.add_relay("wss://relay.damus.io") + cli.connect() + + filter1 = Filter().kind(Kind(0)) + + # filter = Filter().author(keys.public_key()) + print("Syncing Profile Database.. this might take a while..") + dbopts = NegentropyOptions().direction(NegentropyDirection.DOWN) + cli.reconcile(filter1, dbopts) + print("Done Syncing Profile Database.") + + +# We build an example here that we can call by either calling this file directly from the main directory, +# or by adding it to our playground. You can call the example and adjust it to your needs or redefine it in the +# playground or elsewhere +def build_example(name, identifier, admin_config): + dvm_config = build_default_config(identifier) + dvm_config.USE_OWN_VENV = False + dvm_config.SHOWLOG = True + dvm_config.SCHEDULE_UPDATES_SECONDS = 600 # Every 10 seconds + # Add NIP89 + nip89info = { + "name": name, + "image": "https://image.nostr.build/981b560820bc283c58de7989b7abc6664996b487a531d852e4ef7322586a2122.jpg", + "about": "I hunt down bot farms.", + "encryptionSupported": True, + "cashuAccepted": True, + "action": "mute", # follow, unfollow, mute, unmute + "nip90Params": { + "max_results": { + "required": False, + "values": [], + "description": "The number of maximum results to return (default currently 20)" + } + } + } + + nip89config = NIP89Config() + nip89config.DTAG = check_and_set_d_tag(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) + nip89config.CONTENT = json.dumps(nip89info) + + options = {"relay": "wss://relay.damus.io"} + + return DiscoveryBotFarms(name=name, dvm_config=dvm_config, nip89config=nip89config, + admin_config=admin_config, options=options) + + +if __name__ == '__main__': + process_venv(DiscoveryBotFarms) diff --git a/nostr_dvm/tasks/discovery_censor_wot.py b/nostr_dvm/tasks/discovery_censor_wot.py new file mode 100644 index 0000000..846b8d0 --- /dev/null +++ b/nostr_dvm/tasks/discovery_censor_wot.py @@ -0,0 +1,197 @@ +import json +import os +from datetime import timedelta +from threading import Thread + +from nostr_sdk import Client, Timestamp, PublicKey, Tag, Keys, Options, SecretKey, NostrSigner, Kind, RelayOptions, \ + RelayLimits, Event + +from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv +from nostr_dvm.utils.admin_utils import AdminConfig +from nostr_dvm.utils.definitions import EventDefinitions +from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config +from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag +from nostr_dvm.utils.output_utils import post_process_list_to_users + +""" +This File contains a Module to find inactive follows for a user on nostr + +Accepted Inputs: None needed +Outputs: A list of users that have been inactive +Params: None +""" + + +class DiscoverReports(DVMTaskInterface): + KIND: Kind = EventDefinitions.KIND_NIP90_PEOPLE_DISCOVERY + TASK: str = "people to mute" + FIX_COST: float = 0 + client: Client + dvm_config: DVMConfig + + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, + admin_config: AdminConfig = None, options=None): + dvm_config.SCRIPT = os.path.abspath(__file__) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) + + def is_input_supported(self, tags, client=None, dvm_config=None): + return True + + def create_request_from_nostr_event(self, event, client=None, dvm_config=None): + self.dvm_config = dvm_config + + request_form = {"jobID": event.id().to_hex()} + + # default values + users = [] + sender = event.author().to_hex() + since_days = 90 + # users.append(event.author().to_hex()) + + for tag in event.tags(): + if tag.as_vec()[0] == 'i': + users.append(tag.as_vec()[1]) + elif tag.as_vec()[0] == 'param': + param = tag.as_vec()[1] + if param == "since_days": # check for param type + since_days = int(tag.as_vec()[2]) + + options = { + "users": users, + "sender": sender, + "since_days": since_days, + } + request_form['options'] = json.dumps(options) + return request_form + + def process(self, request_form): + from nostr_sdk import Filter + from types import SimpleNamespace + ns = SimpleNamespace() + relaylimits = RelayLimits.disable() + opts = ( + Options().wait_for_send(False).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT)).relay_limits( + relaylimits)) + sk = SecretKey.from_hex(self.dvm_config.PRIVATE_KEY) + keys = Keys.parse(sk.to_hex()) + signer = NostrSigner.keys(keys) + cli = Client.with_opts(signer, opts) + # cli.add_relay("wss://relay.nostr.band") + for relay in self.dvm_config.RELAY_LIST: + cli.add_relay(relay) + # add nostr band, too. + ropts = RelayOptions().ping(False) + cli.add_relay_with_opts("wss://nostr.band", ropts) + + cli.connect() + + options = DVMTaskInterface.set_options(request_form) + step = 20 + + pubkeys = [] + for user in options["users"]: + pubkeys.append(PublicKey.parse(user)) + + # if we don't add users, e.g. by a wot, we check all our followers. + if len(pubkeys) == 0: + followers_filter = Filter().author(PublicKey.parse(options["sender"])).kind(Kind(3)) + followers = cli.get_events_of([followers_filter], timedelta(seconds=5)) + + if len(followers) > 0: + result_list = [] + newest = 0 + best_entry = followers[0] + for entry in followers: + print(len(best_entry.tags())) + print(best_entry.created_at().as_secs()) + if entry.created_at().as_secs() > newest: + newest = entry.created_at().as_secs() + best_entry = entry + for tag in best_entry.tags(): + if tag.as_vec()[0] == "p": + following = PublicKey.parse(tag.as_vec()[1]) + pubkeys.append(following) + + ago = Timestamp.now().as_secs() - 60*60*24*int(options["since_days"]) #TODO make this an option, 180 days for now + since = Timestamp.from_secs(ago) + kind1984_filter = Filter().authors(pubkeys).kind(Kind(1984)).since(since) + reports = cli.get_events_of([kind1984_filter], timedelta(seconds=self.dvm_config.RELAY_TIMEOUT)) + + bad_actors = [] + ns.dic = {} + reasons = ["spam", "illegal", "impersonation"] + # init + for report in reports: + for tag in report.tags(): + if tag.as_vec()[0] == "p": + ns.dic[tag.as_vec()[1]] = 0 + + for report in reports: + #print(report.as_json()) + for tag in report.tags(): + if tag.as_vec()[0] == "p": + if len(tag.as_vec()) > 2 and tag.as_vec()[2] in reasons or len(tag.as_vec()) <= 2: + ns.dic[tag.as_vec()[1]] += 1 + + #print(ns.dic.items()) + # result = {k for (k, v) in ns.dic.items() if v > 0} + # result = sorted(ns.dic.items(), key=lambda x: x[1], reverse=True) + finallist_sorted = sorted(ns.dic.items(), key=lambda x: x[1], reverse=True) + converted_dict = dict(finallist_sorted) + print(json.dumps(converted_dict)) + for k, v in converted_dict.items(): + print(k) + p_tag = Tag.parse(["p", k, str(v)]) + bad_actors.append(p_tag.as_vec()) + + print(json.dumps(bad_actors)) + return json.dumps(bad_actors) + + def post_process(self, result, event): + """Overwrite the interface function to return a social client readable format, if requested""" + for tag in event.tags(): + if tag.as_vec()[0] == 'output': + format = tag.as_vec()[1] + if format == "text/plain": # check for output type + result = post_process_list_to_users(result) + + # if not text/plain, don't post-process + return result + + +# We build an example here that we can call by either calling this file directly from the main directory, +# or by adding it to our playground. You can call the example and adjust it to your needs or redefine it in the +# playground or elsewhere +def build_example(name, identifier, admin_config): + dvm_config = build_default_config(identifier) + dvm_config.USE_OWN_VENV = False + admin_config.LUD16 = dvm_config.LN_ADDRESS + # Add NIP89 + nip89info = { + "name": name, + "image": "https://image.nostr.build/19872a2edd866258fa9eab137631efda89310d52b2c6ea8f99ef057325aa1c7b.jpg", + "about": "I show users that have been reported by either your followers or your Web of Trust. Note: Anyone can report, so you might double check and decide for yourself who to mute. Considers spam, illegal and impersonation reports. Notice: This works with NIP51 mute lists. Not all clients support the new mute list format.", + "encryptionSupported": True, + "cashuAccepted": True, + "action": "mute", # follow, unfollow, mute, unmute + "nip90Params": { + "since_days": { + "required": False, + "values": [], + "description": "The number of days a report is ago in order to be considered " + } + } + } + nip89config = NIP89Config() + nip89config.DTAG = check_and_set_d_tag(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) + nip89config.CONTENT = json.dumps(nip89info) + + + return DiscoverReports(name=name, dvm_config=dvm_config, nip89config=nip89config, + admin_config=admin_config) + + +if __name__ == '__main__': + process_venv(DiscoverReports) diff --git a/nostr_dvm/tasks/discovery_inactive_follows.py b/nostr_dvm/tasks/discovery_inactive_follows.py index 13a17d8..6ab55bc 100644 --- a/nostr_dvm/tasks/discovery_inactive_follows.py +++ b/nostr_dvm/tasks/discovery_inactive_follows.py @@ -3,12 +3,14 @@ import os from datetime import timedelta from threading import Thread -from nostr_sdk import Client, Timestamp, PublicKey, Tag, Keys, Options, SecretKey, ClientSigner +from nostr_sdk import Client, Timestamp, PublicKey, Tag, Keys, Options, SecretKey, NostrSigner, Kind, RelayOptions, \ + RelayLimits from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.output_utils import post_process_list_to_users @@ -22,16 +24,17 @@ Params: None class DiscoverInactiveFollows(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_PEOPLE_DISCOVERY - TASK: str = "inactive-follows" - FIX_COST: float = 50 + KIND: Kind = EventDefinitions.KIND_NIP90_PEOPLE_DISCOVERY + TASK: str = "inactive-followings" + FIX_COST: float = 100 client: Client dvm_config: DVMConfig - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): # no input required @@ -66,39 +69,58 @@ class DiscoverInactiveFollows(DVMTaskInterface): from types import SimpleNamespace ns = SimpleNamespace() - opts = (Options().wait_for_send(False).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT))) sk = SecretKey.from_hex(self.dvm_config.PRIVATE_KEY) - keys = Keys.from_sk_str(sk.to_hex()) - signer = ClientSigner.keys(keys) + keys = Keys.parse(sk.to_hex()) + signer = NostrSigner.keys(keys) + + #relaylimits = RelayLimits().event_max_num_tags(max_num_tags=10000) + #relaylimits.event_max_size(None) + relaylimits = RelayLimits.disable() + + opts = (Options().wait_for_send(False).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT))).relay_limits(relaylimits) + + cli = Client.with_opts(signer, opts) for relay in self.dvm_config.RELAY_LIST: cli.add_relay(relay) + ropts = RelayOptions().ping(False) + cli.add_relay_with_opts("wss://nostr.band", ropts) + cli.connect() options = DVMTaskInterface.set_options(request_form) step = 20 - followers_filter = Filter().author(PublicKey.from_hex(options["user"])).kind(3).limit(1) - followers = cli.get_events_of([followers_filter], timedelta(seconds=self.dvm_config.RELAY_TIMEOUT)) + followers_filter = Filter().author(PublicKey.parse(options["user"])).kind(Kind(3)) + followers = cli.get_events_of([followers_filter], timedelta(seconds=5)) + if len(followers) > 0: result_list = [] newest = 0 best_entry = followers[0] for entry in followers: + print(len(best_entry.tags())) + print(best_entry.created_at().as_secs()) if entry.created_at().as_secs() > newest: newest = entry.created_at().as_secs() best_entry = entry + print(best_entry.as_json()) + print(len(best_entry.tags())) + print(best_entry.created_at().as_secs()) + print(Timestamp.now().as_secs()) followings = [] ns.dic = {} + tagcount = 0 for tag in best_entry.tags(): + tagcount += 1 if tag.as_vec()[0] == "p": following = tag.as_vec()[1] followings.append(following) ns.dic[following] = "False" - print("Followings: " + str(len(followings))) + print("Followings: " + str(len(followings)) + " Tags: " + str(tagcount)) not_active_since_seconds = int(options["since_days"]) * 24 * 60 * 60 dif = Timestamp.now().as_secs() - not_active_since_seconds @@ -107,10 +129,10 @@ class DiscoverInactiveFollows(DVMTaskInterface): def scanList(users, instance, i, st, notactivesince): from nostr_sdk import Filter - keys = Keys.from_sk_str(self.dvm_config.PRIVATE_KEY) + keys = Keys.parse(self.dvm_config.PRIVATE_KEY) opts = Options().wait_for_send(True).send_timeout( timedelta(seconds=5)).skip_disconnected_relays(True) - signer = ClientSigner.keys(keys) + signer = NostrSigner.keys(keys) cli = Client.with_opts(signer, opts) for relay in self.dvm_config.RELAY_LIST: cli.add_relay(relay) @@ -122,7 +144,7 @@ class DiscoverInactiveFollows(DVMTaskInterface): filters.append(filter1) event_from_authors = cli.get_events_of(filters, timedelta(seconds=10)) for author in event_from_authors: - instance.dic[author.pubkey().to_hex()] = "True" + instance.dic[author.author().to_hex()] = "True" print(str(i) + "/" + str(len(users))) cli.disconnect() @@ -175,12 +197,14 @@ class DiscoverInactiveFollows(DVMTaskInterface): # playground or elsewhere def build_example(name, identifier, admin_config): dvm_config = build_default_config(identifier) + dvm_config.USE_OWN_VENV = False admin_config.LUD16 = dvm_config.LN_ADDRESS # Add NIP89 nip89info = { "name": name, "image": "https://image.nostr.build/c33ca6fc4cc038ca4adb46fdfdfda34951656f87ee364ef59095bae1495ce669.jpg", "about": "I discover users you follow, but that have been inactive on Nostr", + "action": "unfollow", #follow, mute, unmute "encryptionSupported": True, "cashuAccepted": True, "nip90Params": { diff --git a/nostr_dvm/tasks/discovery_nonfollowers.py b/nostr_dvm/tasks/discovery_nonfollowers.py new file mode 100644 index 0000000..df0bb84 --- /dev/null +++ b/nostr_dvm/tasks/discovery_nonfollowers.py @@ -0,0 +1,228 @@ +import json +import os +from datetime import timedelta +from threading import Thread + +from nostr_sdk import Client, Timestamp, PublicKey, Tag, Keys, Options, SecretKey, NostrSigner, Kind, RelayOptions, \ + RelayLimits + +from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv +from nostr_dvm.utils.admin_utils import AdminConfig +from nostr_dvm.utils.definitions import EventDefinitions +from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config +from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag +from nostr_dvm.utils.output_utils import post_process_list_to_users + +""" +This File contains a Module to find inactive follows for a user on nostr + +Accepted Inputs: None needed +Outputs: A list of users that have been inactive +Params: None +""" + + +class DiscoverNonFollowers(DVMTaskInterface): + KIND: Kind = EventDefinitions.KIND_NIP90_PEOPLE_DISCOVERY + TASK: str = "non-followers" + FIX_COST: float = 50 + client: Client + dvm_config: DVMConfig + + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, + admin_config: AdminConfig = None, options=None): + dvm_config.SCRIPT = os.path.abspath(__file__) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) + + def is_input_supported(self, tags, client=None, dvm_config=None): + # no input required + return True + + def create_request_from_nostr_event(self, event, client=None, dvm_config=None): + self.dvm_config = dvm_config + + request_form = {"jobID": event.id().to_hex()} + + # default values + user = event.author().to_hex() + for tag in event.tags(): + if tag.as_vec()[0] == 'param': + param = tag.as_vec()[1] + if param == "user": # check for param type + user = tag.as_vec()[2] + + options = { + "user": user, + } + request_form['options'] = json.dumps(options) + return request_form + + def process(self, request_form): + from nostr_sdk import Filter + from types import SimpleNamespace + ns = SimpleNamespace() + relaylimits = RelayLimits.disable() + opts = (Options().wait_for_send(False).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT)).relay_limits(relaylimits)) + sk = SecretKey.from_hex(self.dvm_config.PRIVATE_KEY) + keys = Keys.parse(sk.to_hex()) + signer = NostrSigner.keys(keys) + cli = Client.with_opts(signer, opts) + # cli.add_relay("wss://relay.nostr.band") + for relay in self.dvm_config.RELAY_LIST: + cli.add_relay(relay) + #add nostr band, too. + ropts = RelayOptions().ping(False) + cli.add_relay_with_opts("wss://nostr.band", ropts) + + cli.connect() + + options = DVMTaskInterface.set_options(request_form) + step = 20 + + followers_filter = Filter().author(PublicKey.from_hex(options["user"])).kind(Kind(3)) + followers = cli.get_events_of([followers_filter], timedelta(seconds=self.dvm_config.RELAY_TIMEOUT)) + + if len(followers) > 0: + result_list = [] + newest = 0 + best_entry = followers[0] + for entry in followers: + if entry.created_at().as_secs() > newest: + newest = entry.created_at().as_secs() + best_entry = entry + + print(best_entry.as_json()) + followings = [] + ns.dic = {} + for tag in best_entry.tags(): + if tag.as_vec()[0] == "p": + following = tag.as_vec()[1] + followings.append(following) + ns.dic[following] = "True" + print("Followings: " + str(len(followings))) + + def scanList(users, instance, i, st): + from nostr_sdk import Filter + + keys = Keys.parse(self.dvm_config.PRIVATE_KEY) + opts = Options().wait_for_send(True).send_timeout( + timedelta(seconds=5)).skip_disconnected_relays(True) + signer = NostrSigner.keys(keys) + cli = Client.with_opts(signer, opts) + for relay in self.dvm_config.RELAY_LIST: + cli.add_relay(relay) + cli.connect() + + for i in range(i, i + st): + filters = [] + filter1 = Filter().author(PublicKey.from_hex(users[i])).kind(Kind(3)) + filters.append(filter1) + followers = cli.get_events_of(filters, timedelta(seconds=3)) + + if len(followers) > 0: + result_list = [] + newest = 0 + best_entry = followers[0] + for entry in followers: + if entry.created_at().as_secs() > newest: + newest = entry.created_at().as_secs() + best_entry = entry + + foundfollower = False + for tag in best_entry.tags(): + if tag.as_vec()[0] == "p": + if len(tag.as_vec()) > 1: + if tag.as_vec()[1] == options["user"]: + foundfollower = True + break + + if not foundfollower: + instance.dic[best_entry.author().to_hex()] = "False" + print("DIDNT FIND " + best_entry.author().to_nostr_uri()) + + print(str(i) + "/" + str(len(users))) + cli.disconnect() + + threads = [] + begin = 0 + # Spawn some threads to speed things up + while begin < len(followings) - step: + args = [followings, ns, begin, step] + t = Thread(target=scanList, args=args) + threads.append(t) + begin = begin + step - 1 + + # last to step size + missing_scans = (len(followings) - begin) + args = [followings, ns, begin, missing_scans] + t = Thread(target=scanList, args=args) + threads.append(t) + + # Start all threads + for x in threads: + x.start() + + # Wait for all of them to finish + for x in threads: + x.join() + + result = {k for (k, v) in ns.dic.items() if v == "False"} + + print("Non backfollowing accounts found: " + str(len(result))) + for k in result: + p_tag = Tag.parse(["p", k]) + result_list.append(p_tag.as_vec()) + + return json.dumps(result_list) + + def post_process(self, result, event): + """Overwrite the interface function to return a social client readable format, if requested""" + for tag in event.tags(): + if tag.as_vec()[0] == 'output': + format = tag.as_vec()[1] + if format == "text/plain": # check for output type + result = post_process_list_to_users(result) + + # if not text/plain, don't post-process + return result + + +# We build an example here that we can call by either calling this file directly from the main directory, +# or by adding it to our playground. You can call the example and adjust it to your needs or redefine it in the +# playground or elsewhere +def build_example(name, identifier, admin_config): + dvm_config = build_default_config(identifier) + dvm_config.USE_OWN_VENV = False + admin_config.LUD16 = dvm_config.LN_ADDRESS + # Add NIP89 + nip89info = { + "name": name, + "image": "https://image.nostr.build/c33ca6fc4cc038ca4adb46fdfdfda34951656f87ee364ef59095bae1495ce669.jpg", + "about": "I discover users you follow, but that don't follow you back.", + "encryptionSupported": True, + "cashuAccepted": True, + "nip90Params": { + "user": { + "required": False, + "values": [], + "description": "Do the task for another user" + }, + "since_days": { + "required": False, + "values": [], + "description": "The number of days a user has not been active to be considered inactive" + } + } + } + nip89config = NIP89Config() + nip89config.DTAG = check_and_set_d_tag(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) + nip89config.CONTENT = json.dumps(nip89info) + + return DiscoverNonFollowers(name=name, dvm_config=dvm_config, nip89config=nip89config, + admin_config=admin_config) + + +if __name__ == '__main__': + process_venv(DiscoverNonFollowers) diff --git a/nostr_dvm/tasks/trending_notes_nostrband.py b/nostr_dvm/tasks/discovery_trending_notes_nostrband.py similarity index 86% rename from nostr_dvm/tasks/trending_notes_nostrband.py rename to nostr_dvm/tasks/discovery_trending_notes_nostrband.py index 5b93078..afab0bc 100644 --- a/nostr_dvm/tasks/trending_notes_nostrband.py +++ b/nostr_dvm/tasks/discovery_trending_notes_nostrband.py @@ -1,11 +1,12 @@ import json import os -from nostr_sdk import Tag +from nostr_sdk import Tag, Kind from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.output_utils import post_process_list_to_events @@ -18,15 +19,16 @@ Params: None class TrendingNotesNostrBand(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_CONTENT_DISCOVERY + KIND: Kind = EventDefinitions.KIND_NIP90_CONTENT_DISCOVERY TASK: str = "trending-content" FIX_COST: float = 0 dvm_config: DVMConfig - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: @@ -76,8 +78,9 @@ class TrendingNotesNostrBand(DVMTaskInterface): return json.dumps(result_list) - except: - return "error" + except Exception as e: + print(e) + return json.dumps([]) def post_process(self, result, event): """Overwrite the interface function to return a social client readable format, if requested""" @@ -96,13 +99,15 @@ class TrendingNotesNostrBand(DVMTaskInterface): # playground or elsewhere def build_example(name, identifier, admin_config): dvm_config = build_default_config(identifier) + dvm_config.USE_OWN_VENV = False admin_config.LUD16 = dvm_config.LN_ADDRESS # Add NIP89 nip89info = { "name": name, - "image": "https://image.nostr.build/c33ca6fc4cc038ca4adb46fdfdfda34951656f87ee364ef59095bae1495ce669.jpg", + "image": "https://nostr.band/android-chrome-192x192.png", "about": "I show trending notes from nostr.band", + "amount": "Free", "encryptionSupported": True, "cashuAccepted": True, "nip90Params": {} diff --git a/nostr_dvm/tasks/imagegeneration_openai_dalle.py b/nostr_dvm/tasks/imagegeneration_openai_dalle.py index e08c5b8..9ac9a95 100644 --- a/nostr_dvm/tasks/imagegeneration_openai_dalle.py +++ b/nostr_dvm/tasks/imagegeneration_openai_dalle.py @@ -5,11 +5,13 @@ from io import BytesIO import requests from PIL import Image +from nostr_sdk import Kind from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.output_utils import upload_media_to_hoster from nostr_dvm.utils.zap_utils import get_price_per_sat @@ -23,16 +25,17 @@ Outputs: An url to an Image class ImageGenerationDALLE(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_GENERATE_IMAGE + KIND: Kind = EventDefinitions.KIND_NIP90_GENERATE_IMAGE TASK: str = "text-to-image" FIX_COST: float = 120 dependencies = [("nostr-dvm", "nostr-dvm"), ("openai", "openai==1.3.5")] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: diff --git a/nostr_dvm/tasks/imagegeneration_replicate_sdxl.py b/nostr_dvm/tasks/imagegeneration_replicate_sdxl.py index f9b83c1..3077fd6 100644 --- a/nostr_dvm/tasks/imagegeneration_replicate_sdxl.py +++ b/nostr_dvm/tasks/imagegeneration_replicate_sdxl.py @@ -3,11 +3,13 @@ import os from io import BytesIO import requests from PIL import Image +from nostr_sdk import Kind from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.output_utils import upload_media_to_hoster from nostr_dvm.utils.zap_utils import get_price_per_sat @@ -22,16 +24,17 @@ Params: class ImageGenerationReplicateSDXL(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_GENERATE_IMAGE + KIND: Kind = EventDefinitions.KIND_NIP90_GENERATE_IMAGE TASK: str = "text-to-image" FIX_COST: float = 120 dependencies = [("nostr-dvm", "nostr-dvm"), ("replicate", "replicate")] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: diff --git a/nostr_dvm/tasks/imagegeneration_sd21_mlx.py b/nostr_dvm/tasks/imagegeneration_sd21_mlx.py index 9330345..e49fe79 100644 --- a/nostr_dvm/tasks/imagegeneration_sd21_mlx.py +++ b/nostr_dvm/tasks/imagegeneration_sd21_mlx.py @@ -1,12 +1,14 @@ import json import os from PIL import Image +from nostr_sdk import Kind from tqdm import tqdm from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.output_utils import upload_media_to_hoster from nostr_dvm.utils.zap_utils import get_price_per_sat @@ -21,7 +23,7 @@ Params: class ImageGenerationMLX(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_GENERATE_IMAGE + KIND: Kind = EventDefinitions.KIND_NIP90_GENERATE_IMAGE TASK: str = "text-to-image" FIX_COST: float = 120 dependencies = [("nostr-dvm", "nostr-dvm"), @@ -32,10 +34,11 @@ class ImageGenerationMLX(DVMTaskInterface): ("tqdm", "tqdm"), ] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: diff --git a/nostr_dvm/tasks/imagegeneration_sdxl.py b/nostr_dvm/tasks/imagegeneration_sdxl.py index dbe5bb7..de64068 100644 --- a/nostr_dvm/tasks/imagegeneration_sdxl.py +++ b/nostr_dvm/tasks/imagegeneration_sdxl.py @@ -1,10 +1,13 @@ import json from multiprocessing.pool import ThreadPool +from nostr_sdk import Kind + from nostr_dvm.backends.nova_server.utils import check_server_status, send_request_to_server from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.definitions import EventDefinitions @@ -19,13 +22,14 @@ Params: -model # models: juggernaut, dynavision, colossusProject, newrea class ImageGenerationSDXL(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_GENERATE_IMAGE + KIND: Kind = EventDefinitions.KIND_NIP90_GENERATE_IMAGE TASK: str = "text-to-image" - FIX_COST: float = 70 + FIX_COST: float = 50 - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: diff --git a/nostr_dvm/tasks/imagegeneration_sdxlimg2img.py b/nostr_dvm/tasks/imagegeneration_sdxlimg2img.py index 751b210..fc656bc 100644 --- a/nostr_dvm/tasks/imagegeneration_sdxlimg2img.py +++ b/nostr_dvm/tasks/imagegeneration_sdxlimg2img.py @@ -1,10 +1,13 @@ import json from multiprocessing.pool import ThreadPool +from nostr_sdk import Kind + from nostr_dvm.backends.nova_server.utils import check_server_status, send_request_to_server from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.definitions import EventDefinitions @@ -19,13 +22,14 @@ Params: -model # models: juggernaut, dynavision, colossusProject, newrea class ImageGenerationSDXLIMG2IMG(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_GENERATE_IMAGE + KIND: Kind = EventDefinitions.KIND_NIP90_GENERATE_IMAGE TASK: str = "image-to-image" FIX_COST: float = 70 - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): hasurl = False diff --git a/nostr_dvm/tasks/imageinterrogator.py b/nostr_dvm/tasks/imageinterrogator.py index a1c3374..92f8ad7 100644 --- a/nostr_dvm/tasks/imageinterrogator.py +++ b/nostr_dvm/tasks/imageinterrogator.py @@ -1,10 +1,13 @@ import json from multiprocessing.pool import ThreadPool +from nostr_sdk import Kind + from nostr_dvm.backends.nova_server.utils import check_server_status, send_request_to_server from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.definitions import EventDefinitions @@ -18,13 +21,14 @@ Outputs: An textual description of the image class ImageInterrogator(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_EXTRACT_TEXT + KIND: Kind = EventDefinitions.KIND_NIP90_EXTRACT_TEXT TASK: str = "image-to-text" FIX_COST: float = 80 - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): hasurl = False diff --git a/nostr_dvm/tasks/imageupscale.py b/nostr_dvm/tasks/imageupscale.py index 695bc2e..e487695 100644 --- a/nostr_dvm/tasks/imageupscale.py +++ b/nostr_dvm/tasks/imageupscale.py @@ -1,10 +1,13 @@ import json from multiprocessing.pool import ThreadPool +from nostr_sdk import Kind + from nostr_dvm.backends.nova_server.utils import check_server_status, send_request_to_server from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.definitions import EventDefinitions @@ -18,13 +21,14 @@ Params: -upscale 2,3,4 class ImageUpscale(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_GENERATE_IMAGE + KIND: Kind = EventDefinitions.KIND_NIP90_GENERATE_IMAGE TASK: str = "image-to-image" FIX_COST: float = 20 - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): hasurl = False @@ -55,12 +59,12 @@ class ImageUpscale(DVMTaskInterface): elif tag.as_vec()[0] == 'param': print("Param: " + tag.as_vec()[1] + ": " + tag.as_vec()[2]) if tag.as_vec()[1] == "upscale": - out_scale = tag.as_vec()[2] + out_scale = int(tag.as_vec()[2]) io_input_image = { "id": "input_image", "type": "input", - "src": "url:Image", + "src": "url:image", "uri": url } @@ -72,10 +76,15 @@ class ImageUpscale(DVMTaskInterface): request_form['data'] = json.dumps([io_input_image, io_output]) - options = { - "outscale": out_scale, + options = {"model": "RealESRGAN_x4plus", + "outscale": out_scale, + "denoise_strength": 0.5, + "tile": 0, + "tile_pad": 10, + "pre_pad": 0, + "compute_type": "fp32", + "face_enhance": False} - } request_form['options'] = json.dumps(options) return request_form diff --git a/nostr_dvm/tasks/search_users.py b/nostr_dvm/tasks/search_users.py new file mode 100644 index 0000000..80b490e --- /dev/null +++ b/nostr_dvm/tasks/search_users.py @@ -0,0 +1,215 @@ +import json +import os +from datetime import timedelta +from nostr_sdk import Client, Timestamp, PublicKey, Tag, Keys, Options, SecretKey, NostrSigner, NostrDatabase, \ + ClientBuilder, Filter, NegentropyOptions, NegentropyDirection, init_logger, LogLevel, Kind + +from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv +from nostr_dvm.utils.admin_utils import AdminConfig +from nostr_dvm.utils.definitions import EventDefinitions +from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config +from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag +from nostr_dvm.utils.output_utils import post_process_list_to_events, post_process_list_to_users + +""" +This File contains a Module to search for notes +Accepted Inputs: a search query +Outputs: A list of events +Params: None +""" + + +class SearchUser(DVMTaskInterface): + KIND: Kind = EventDefinitions.KIND_NIP90_USER_SEARCH + TASK: str = "search-user" + FIX_COST: float = 0 + dvm_config: DVMConfig + last_schedule: int = 0 + + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, + admin_config: AdminConfig = None, options=None): + dvm_config.SCRIPT = os.path.abspath(__file__) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) + + use_logger = False + if use_logger: + init_logger(LogLevel.DEBUG) + + self.sync_db() + + def is_input_supported(self, tags, client=None, dvm_config=None): + for tag in tags: + if tag.as_vec()[0] == 'i': + input_value = tag.as_vec()[1] + input_type = tag.as_vec()[2] + if input_type != "text": + return False + return True + + def create_request_from_nostr_event(self, event, client=None, dvm_config=None): + self.dvm_config = dvm_config + print(self.dvm_config.PRIVATE_KEY) + + request_form = {"jobID": event.id().to_hex()} + + # default values + search = "" + max_results = 100 + + for tag in event.tags(): + if tag.as_vec()[0] == 'i': + input_type = tag.as_vec()[2] + if input_type == "text": + search = tag.as_vec()[1] + elif tag.as_vec()[0] == 'param': + param = tag.as_vec()[1] + if param == "max_results": # check for param type + max_results = int(tag.as_vec()[2]) + + options = { + "search": search, + "max_results": max_results, + } + request_form['options'] = json.dumps(options) + return request_form + + def process(self, request_form): + from nostr_sdk import Filter + options = DVMTaskInterface.set_options(request_form) + + opts = (Options().wait_for_send(False).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT))) + sk = SecretKey.from_hex(self.dvm_config.PRIVATE_KEY) + keys = Keys.parse(sk.to_hex()) + signer = NostrSigner.keys(keys) + + database = NostrDatabase.sqlite("db/nostr_profiles.db") + cli = ClientBuilder().database(database).signer(signer).opts(opts).build() + + cli.add_relay("wss://relay.damus.io") + # cli.add_relay("wss://atl.purplerelay.com") + cli.connect() + + # Negentropy reconciliation + + # Query events from database + + filter1 = Filter().kind(Kind(0)) + events = cli.database().query([filter1]) + # for event in events: + # print(event.as_json()) + + # events = cli.get_events_of([notes_filter], timedelta(seconds=5)) + + result_list = [] + print("Events: " + str(len(events))) + index = 0 + if len(events) > 0: + + for event in events: + if index < options["max_results"]: + try: + if options["search"].lower() in event.content().lower(): + p_tag = Tag.parse(["p", event.author().to_hex()]) + print(event.as_json()) + result_list.append(p_tag.as_vec()) + index += 1 + except Exception as exp: + print(str(exp) + " " + event.author().to_hex()) + else: + break + + return json.dumps(result_list) + + def post_process(self, result, event): + """Overwrite the interface function to return a social client readable format, if requested""" + for tag in event.tags(): + if tag.as_vec()[0] == 'output': + format = tag.as_vec()[1] + if format == "text/plain": # check for output type + result = post_process_list_to_users(result) + + # if not text/plain, don't post-process + return result + + def schedule(self, dvm_config): + if dvm_config.SCHEDULE_UPDATES_SECONDS == 0: + return 0 + else: + if Timestamp.now().as_secs() >= self.last_schedule + dvm_config.SCHEDULE_UPDATES_SECONDS: + self.sync_db() + self.last_schedule = Timestamp.now().as_secs() + return 1 + + def sync_db(self): + opts = (Options().wait_for_send(False).send_timeout(timedelta(seconds=self.dvm_config.RELAY_TIMEOUT))) + sk = SecretKey.from_hex(self.dvm_config.PRIVATE_KEY) + keys = Keys.parse(sk.to_hex()) + signer = NostrSigner.keys(keys) + database = NostrDatabase.sqlite("db/nostr_profiles.db") + cli = ClientBuilder().signer(signer).database(database).opts(opts).build() + + cli.add_relay("wss://relay.damus.io") + cli.connect() + + filter1 = Filter().kind(Kind(0)) + + # filter = Filter().author(keys.public_key()) + print("Syncing Profile Database.. this might take a while..") + dbopts = NegentropyOptions().direction(NegentropyDirection.DOWN) + cli.reconcile(filter1, dbopts) + print("Done Syncing Profile Database.") + + +# We build an example here that we can call by either calling this file directly from the main directory, +# or by adding it to our playground. You can call the example and adjust it to your needs or redefine it in the +# playground or elsewhere +def build_example(name, identifier, admin_config): + dvm_config = build_default_config(identifier) + dvm_config.USE_OWN_VENV = False + dvm_config.SHOWLOG = True + dvm_config.SCHEDULE_UPDATES_SECONDS = 600 # Every 10 seconds + # Add NIP89 + nip89info = { + "name": name, + "image": "https://image.nostr.build/a99ab925084029d9468fef8330ff3d9be2cf67da473b024f2a6d48b5cd77197f.jpg", + "about": "I search users.", + "encryptionSupported": True, + "cashuAccepted": True, + "nip90Params": { + "users": { + "required": False, + "values": [], + "description": "Search for content from specific users" + }, + "since": { + "required": False, + "values": [], + "description": "A unix timestamp in the past from where the search should start" + }, + "until": { + "required": False, + "values": [], + "description": "A unix timestamp that tells until the search should include results" + }, + "max_results": { + "required": False, + "values": [], + "description": "The number of maximum results to return (default currently 20)" + } + } + } + + nip89config = NIP89Config() + nip89config.DTAG = check_and_set_d_tag(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) + nip89config.CONTENT = json.dumps(nip89info) + + options = {"relay": "wss://relay.damus.io"} + + return SearchUser(name=name, dvm_config=dvm_config, nip89config=nip89config, + admin_config=admin_config, options=options) + + +if __name__ == '__main__': + process_venv(SearchUser) diff --git a/nostr_dvm/tasks/summarization_huggingchat.py b/nostr_dvm/tasks/summarization_huggingchat.py index 8e38951..00585e2 100644 --- a/nostr_dvm/tasks/summarization_huggingchat.py +++ b/nostr_dvm/tasks/summarization_huggingchat.py @@ -1,13 +1,15 @@ import json import os +import re from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag -from nostr_dvm.utils.nostr_utils import get_referenced_event_by_id, get_event_by_id -from nostr_sdk import Tag +from nostr_dvm.utils.nostr_utils import get_referenced_event_by_id, get_event_by_id, get_events_by_ids +from nostr_sdk import Tag, Kind """ This File contains a Module to summarize Text, based on a prompt using a the HuggingChat LLM on Huggingface @@ -18,16 +20,17 @@ Outputs: Generated text class TextSummarizationHuggingChat(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_SUMMARIZE_TEXT + KIND: Kind = EventDefinitions.KIND_NIP90_SUMMARIZE_TEXT TASK: str = "summarization" FIX_COST: float = 0 dependencies = [("nostr-dvm", "nostr-dvm"), ("hugchat", "hugchat")] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: @@ -42,15 +45,17 @@ class TextSummarizationHuggingChat(DVMTaskInterface): def create_request_from_nostr_event(self, event, client=None, dvm_config=None): request_form = {"jobID": event.id().to_hex() + "_" + self.NAME.replace(" ", "")} prompt = "" + collect_events = [] for tag in event.tags(): if tag.as_vec()[0] == 'i': input_type = tag.as_vec()[2] if input_type == "text": - prompt = tag.as_vec()[1] + prompt += tag.as_vec()[1] + "\n" elif input_type == "event": - evt = get_event_by_id(tag.as_vec()[1], client=client, config=dvm_config) - prompt = evt.content() + collect_events.append(tag.as_vec()[1]) + # evt = get_event_by_id(tag.as_vec()[1], client=client, config=dvm_config) + # prompt += evt.content() + "\n" elif input_type == "job": evt = get_referenced_event_by_id(event_id=tag.as_vec()[1], client=client, kinds=[EventDefinitions.KIND_NIP90_RESULT_EXTRACT_TEXT, @@ -61,7 +66,7 @@ class TextSummarizationHuggingChat(DVMTaskInterface): if evt is None: print("Event not found") raise Exception - + if evt.kind() == EventDefinitions.KIND_NIP90_RESULT_CONTENT_DISCOVERY: result_list = json.loads(evt.content()) prompt = "" @@ -72,9 +77,17 @@ class TextSummarizationHuggingChat(DVMTaskInterface): else: prompt = evt.content() + + evts = get_events_by_ids(collect_events, client=client, config=dvm_config) + if evts is not None: + for evt in evts: + prompt += evt.content() + "\n" + + clean_prompt = re.sub(r'^https?:\/\/.*[\r\n]*', '', prompt, flags=re.MULTILINE) options = { - "prompt": prompt, + "prompt": clean_prompt[:4000], } + request_form['options'] = json.dumps(options) return request_form @@ -91,12 +104,11 @@ class TextSummarizationHuggingChat(DVMTaskInterface): cookies = sign.login() sign.saveCookiesToDir(cookie_path_dir) - options = DVMTaskInterface.set_options(request_form) try: chatbot = hugchat.ChatBot(cookies=cookies.get_dict()) # or cookie_path="usercookies/.json" - query_result = chatbot.query("Summarize the following text in maximum 5 sentences: " + options["prompt"]) + query_result = chatbot.query("Summarize the following notes: " + str(options["prompt"])) print(query_result["text"]) # or query_result.text or query_result["text"] return str(query_result["text"]).lstrip() @@ -105,7 +117,6 @@ class TextSummarizationHuggingChat(DVMTaskInterface): raise Exception(e) - # We build an example here that we can call by either calling this file directly from the main directory, # or by adding it to our playground. You can call the example and adjust it to your needs or redefine it in the # playground or elsewhere @@ -115,8 +126,8 @@ def build_example(name, identifier, admin_config): nip89info = { "name": name, - "image": "https://image.nostr.build/c33ca6fc4cc038ca4adb46fdfdfda34951656f87ee364ef59095bae1495ce669.jpg", - "about": "I use a LLM connected via Huggingchat", + "image": "https://image.nostr.build/720eadc9af89084bb09de659af43ad17fec1f4b0887084e83ac0ae708dfa83a6.png", + "about": "I use a LLM connected via Huggingchat to summarize Inputs", "encryptionSupported": True, "cashuAccepted": True, "nip90Params": {} @@ -127,7 +138,7 @@ def build_example(name, identifier, admin_config): nip89config.CONTENT = json.dumps(nip89info) return TextSummarizationHuggingChat(name=name, dvm_config=dvm_config, nip89config=nip89config, - admin_config=admin_config) + admin_config=admin_config) if __name__ == '__main__': diff --git a/nostr_dvm/tasks/summarization_unleashed_chat.py b/nostr_dvm/tasks/summarization_unleashed_chat.py new file mode 100644 index 0000000..e9e5345 --- /dev/null +++ b/nostr_dvm/tasks/summarization_unleashed_chat.py @@ -0,0 +1,170 @@ +import json +import os +import re +from nostr_sdk import Tag, Kind +from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv +from nostr_dvm.utils.admin_utils import AdminConfig +from nostr_dvm.utils.definitions import EventDefinitions +from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config +from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag +from nostr_dvm.utils.nostr_utils import get_referenced_event_by_id, get_events_by_ids, get_event_by_id + +""" +This File contains a Module to generate Text, based on a prompt using the Unleashed.chat API. + +Accepted Inputs: Prompt (text) +Outputs: Generated text +""" + + +class SummarizationUnleashedChat(DVMTaskInterface): + KIND: Kind = EventDefinitions.KIND_NIP90_SUMMARIZE_TEXT + TASK: str = "text-to-text" + FIX_COST: float = 10 + dependencies = [("nostr-dvm", "nostr-dvm"), + ("openai", "openai")] + + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, + admin_config: AdminConfig = None, options=None): + dvm_config.SCRIPT = os.path.abspath(__file__) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) + + def is_input_supported(self, tags, client=None, dvm_config=None): + for tag in tags: + if tag.as_vec()[0] == 'i': + print(tag.as_vec()) + input_value = tag.as_vec()[1] + input_type = tag.as_vec()[2] + if input_type != "event" and input_type != "job" and input_type != "text": + return False + + return True + + def create_request_from_nostr_event(self, event, client=None, dvm_config=None): + request_form = {"jobID": event.id().to_hex() + "_" + self.NAME.replace(" ", "")} + prompt = "" + collect_events = [] + nostr_mode = True + + for tag in event.tags(): + if tag.as_vec()[0] == 'i': + input_type = tag.as_vec()[2] + if input_type == "text": + prompt += tag.as_vec()[1] + "\n" + elif input_type == "event": + collect_events.append(tag.as_vec()[1]) + # evt = get_event_by_id(tag.as_vec()[1], client=client, config=dvm_config) + # prompt += evt.content() + "\n" + elif input_type == "job": + evt = get_referenced_event_by_id(event_id=tag.as_vec()[1], client=client, + kinds=[EventDefinitions.KIND_NIP90_RESULT_EXTRACT_TEXT, + EventDefinitions.KIND_NIP90_RESULT_SUMMARIZE_TEXT, + EventDefinitions.KIND_NIP90_RESULT_TRANSLATE_TEXT, + EventDefinitions.KIND_NIP90_RESULT_CONTENT_DISCOVERY], + dvm_config=dvm_config) + if evt is None: + print("Event not found") + raise Exception + + if evt.kind() == EventDefinitions.KIND_NIP90_RESULT_CONTENT_DISCOVERY: + result_list = json.loads(evt.content()) + prompt = "" + for tag in result_list: + e_tag = Tag.parse(tag) + evt = get_event_by_id(e_tag.as_vec()[1], client=client, config=dvm_config) + prompt += evt.content() + "\n" + + else: + prompt = evt.content() + + evts = get_events_by_ids(collect_events, client=client, config=dvm_config) + if evts is not None: + for evt in evts: + prompt += evt.content() + "\n" + + clean_prompt = re.sub(r'^https?:\/\/.*[\r\n]*', '', prompt, flags=re.MULTILINE) + options = { + "prompt": clean_prompt[:4000], + "nostr": nostr_mode, + } + request_form['options'] = json.dumps(options) + + return request_form + + def process(self, request_form): + from openai import OpenAI + temp_open_ai_api_key = os.environ["OPENAI_API_KEY"] + os.environ["OPENAI_API_KEY"] = os.getenv("UNLEASHED_API_KEY") + options = DVMTaskInterface.set_options(request_form) + + try: + client = OpenAI( + base_url='https://unleashed.chat/api/v1', + ) + + print('Models:\n') + + for model in client.models.list(): + print('- ' + model.id) + + content = "Summarize the following notes: " + str(options["prompt"]) + normal_stream = client.chat.completions.create( + messages=[ + { + 'role': 'user', + 'content': content, + } + ], + model='dolphin-2.2.1-mistral-7b', + stream=True, + extra_body={ + 'nostr_mode': options["nostr"], + }, + ) + + print('\nChat response: ', end='') + + result = "" + for chunk in normal_stream: + result += chunk.choices[0].delta.content + print(chunk.choices[0].delta.content, end='') + + os.environ["OPENAI_API_KEY"] = temp_open_ai_api_key + return result + + except Exception as e: + print("Error in Module: " + str(e)) + raise Exception(e) + + +# We build an example here that we can call by either calling this file directly from the main directory, +# or by adding it to our playground. You can call the example and adjust it to your needs or redefine it in the +# playground or elsewhere +def build_example(name, identifier, admin_config): + dvm_config = build_default_config(identifier) + dvm_config.SEND_FEEDBACK_EVENTS = True + admin_config.LUD16 = dvm_config.LN_ADDRESS + + nip89info = { + "name": name, + "image": "https://unleashed.chat/_app/immutable/assets/hero.pehsu4x_.jpeg", + "about": "I summarize Text with https://unleashed.chat", + "encryptionSupported": True, + "cashuAccepted": True, + "nip90Params": {} + } + + nip89config = NIP89Config() + nip89config.DTAG = check_and_set_d_tag(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) + nip89config.CONTENT = json.dumps(nip89info) + admin_config2 = AdminConfig() + admin_config2.REBROADCAST_NIP89 = False + + return SummarizationUnleashedChat(name=name, dvm_config=dvm_config, nip89config=nip89config, + admin_config=admin_config2) + + +if __name__ == '__main__': + process_venv(SummarizationUnleashedChat) diff --git a/nostr_dvm/tasks/textextraction_google.py b/nostr_dvm/tasks/textextraction_google.py index 3a7d649..e737288 100644 --- a/nostr_dvm/tasks/textextraction_google.py +++ b/nostr_dvm/tasks/textextraction_google.py @@ -2,10 +2,13 @@ import json import os import time +from nostr_sdk import Kind + from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config from nostr_dvm.utils.mediasource_utils import organize_input_media_data +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.definitions import EventDefinitions @@ -19,17 +22,18 @@ Outputs: Transcribed text class SpeechToTextGoogle(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_EXTRACT_TEXT + KIND: Kind = EventDefinitions.KIND_NIP90_EXTRACT_TEXT TASK: str = "speech-to-text" FIX_COST: float = 10 PER_UNIT_COST: float = 0.1 dependencies = [("nostr-dvm", "nostr-dvm"), ("speech_recognition", "SpeechRecognition==3.10.0")] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) if options is None: self.options = {} diff --git a/nostr_dvm/tasks/textextraction_pdf.py b/nostr_dvm/tasks/textextraction_pdf.py index 2bc771b..5a830dd 100644 --- a/nostr_dvm/tasks/textextraction_pdf.py +++ b/nostr_dvm/tasks/textextraction_pdf.py @@ -2,10 +2,13 @@ import json import os import re +from nostr_sdk import Kind + from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.nostr_utils import get_event_by_id @@ -19,16 +22,17 @@ Params: None class TextExtractionPDF(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_EXTRACT_TEXT + KIND: Kind = EventDefinitions.KIND_NIP90_EXTRACT_TEXT TASK: str = "pdf-to-text" FIX_COST: float = 0 dependencies = [("nostr-dvm", "nostr-dvm"), ("pypdf", "pypdf==3.17.1")] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: diff --git a/nostr_dvm/tasks/textextraction_whisperx.py b/nostr_dvm/tasks/textextraction_whisperx.py index d33436c..282dbd9 100644 --- a/nostr_dvm/tasks/textextraction_whisperx.py +++ b/nostr_dvm/tasks/textextraction_whisperx.py @@ -2,11 +2,15 @@ import json import os import time from multiprocessing.pool import ThreadPool + +from nostr_sdk import Kind + from nostr_dvm.backends.nova_server.utils import check_server_status, send_request_to_server, send_file_to_server from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config from nostr_dvm.utils.mediasource_utils import organize_input_media_data +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.definitions import EventDefinitions @@ -20,14 +24,16 @@ Outputs: Transcribed text class SpeechToTextWhisperX(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_EXTRACT_TEXT + KIND: Kind = EventDefinitions.KIND_NIP90_EXTRACT_TEXT TASK: str = "speech-to-text" FIX_COST: float = 10 PER_UNIT_COST: float = 0.1 - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): - super().__init__(name, dvm_config, nip89config, admin_config, options) + dvm_config.SCRIPT = os.path.abspath(__file__) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: diff --git a/nostr_dvm/tasks/textgeneration_huggingchat.py b/nostr_dvm/tasks/textgeneration_huggingchat.py index 650338d..0776f85 100644 --- a/nostr_dvm/tasks/textgeneration_huggingchat.py +++ b/nostr_dvm/tasks/textgeneration_huggingchat.py @@ -1,10 +1,13 @@ import json import os +from nostr_sdk import Kind + from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag """ @@ -16,16 +19,17 @@ Outputs: Generated text class TextGenerationHuggingChat(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_GENERATE_TEXT + KIND: Kind = EventDefinitions.KIND_NIP90_GENERATE_TEXT TASK: str = "text-to-text" FIX_COST: float = 0 dependencies = [("nostr-dvm", "nostr-dvm"), ("hugchat", "hugchat")] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: @@ -89,7 +93,7 @@ def build_example(name, identifier, admin_config): nip89info = { "name": name, - "image": "https://image.nostr.build/c33ca6fc4cc038ca4adb46fdfdfda34951656f87ee364ef59095bae1495ce669.jpg", + "image": "https://image.nostr.build/720eadc9af89084bb09de659af43ad17fec1f4b0887084e83ac0ae708dfa83a6.png", "about": "I use a LLM connected via Huggingchat", "encryptionSupported": True, "cashuAccepted": True, diff --git a/nostr_dvm/tasks/textgeneration_llmlite.py b/nostr_dvm/tasks/textgeneration_llmlite.py index beee0cc..e6ff667 100644 --- a/nostr_dvm/tasks/textgeneration_llmlite.py +++ b/nostr_dvm/tasks/textgeneration_llmlite.py @@ -1,10 +1,13 @@ import json import os +from nostr_sdk import Kind + from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag """ @@ -16,16 +19,17 @@ Outputs: Generated text class TextGenerationLLMLite(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_GENERATE_TEXT + KIND: Kind = EventDefinitions.KIND_NIP90_GENERATE_TEXT TASK: str = "text-to-text" FIX_COST: float = 0 dependencies = [("nostr-dvm", "nostr-dvm"), ("litellm", "litellm==1.12.3")] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: diff --git a/nostr_dvm/tasks/textgeneration_unleashed_chat.py b/nostr_dvm/tasks/textgeneration_unleashed_chat.py new file mode 100644 index 0000000..8cee96e --- /dev/null +++ b/nostr_dvm/tasks/textgeneration_unleashed_chat.py @@ -0,0 +1,134 @@ +import json +import os + +from nostr_sdk import Kind + +from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv +from nostr_dvm.utils.admin_utils import AdminConfig +from nostr_dvm.utils.definitions import EventDefinitions +from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config +from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag + +""" +This File contains a Module to generate Text, based on a prompt using the Unleashed.chat API. + +Accepted Inputs: Prompt (text) +Outputs: Generated text +""" + + +class TextGenerationUnleashedChat(DVMTaskInterface): + KIND: Kind = EventDefinitions.KIND_NIP90_GENERATE_TEXT + TASK: str = "text-to-text" + FIX_COST: float = 10 + dependencies = [("nostr-dvm", "nostr-dvm"), + ("openai", "openai")] + + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, + admin_config: AdminConfig = None, options=None): + dvm_config.SCRIPT = os.path.abspath(__file__) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) + + def is_input_supported(self, tags, client=None, dvm_config=None): + for tag in tags: + if tag.as_vec()[0] == 'i': + input_value = tag.as_vec()[1] + input_type = tag.as_vec()[2] + if input_type != "text": + return False + + return True + + def create_request_from_nostr_event(self, event, client=None, dvm_config=None): + request_form = {"jobID": event.id().to_hex() + "_" + self.NAME.replace(" ", "")} + prompt = "" + nostr_mode= True + + for tag in event.tags(): + if tag.as_vec()[0] == 'i': + input_type = tag.as_vec()[2] + if input_type == "text": + prompt = tag.as_vec()[1] + + options = { + "prompt": prompt, + "nostr": nostr_mode, + } + request_form['options'] = json.dumps(options) + + return request_form + + def process(self, request_form): + from openai import OpenAI + temp_open_ai_api_key = os.environ["OPENAI_API_KEY"] + os.environ["OPENAI_API_KEY"] = os.getenv("UNLEASHED_API_KEY") + options = DVMTaskInterface.set_options(request_form) + + try: + client = OpenAI( + base_url='https://unleashed.chat/api/v1', + ) + + print('Models:\n') + + for model in client.models.list(): + print('- ' + model.id) + + normal_stream = client.chat.completions.create( + messages=[ + { + 'role': 'user', + 'content': options["prompt"], + } + ], + model='dolphin-2.2.1-mistral-7b', + stream=True, + extra_body={ + 'nostr_mode': options["nostr"], + }, + ) + + print('\nChat response: ', end='') + + result = "" + for chunk in normal_stream: + result += chunk.choices[0].delta.content + print(chunk.choices[0].delta.content, end='') + + os.environ["OPENAI_API_KEY"] = temp_open_ai_api_key + return result + + except Exception as e: + print("Error in Module: " + str(e)) + raise Exception(e) + + +# We build an example here that we can call by either calling this file directly from the main directory, +# or by adding it to our playground. You can call the example and adjust it to your needs or redefine it in the +# playground or elsewhere +def build_example(name, identifier, admin_config): + dvm_config = build_default_config(identifier) + dvm_config.SEND_FEEDBACK_EVENTS = False + admin_config.LUD16 = dvm_config.LN_ADDRESS + + + nip89info = { + "name": name, + "image": "https://unleashed.chat/_app/immutable/assets/hero.pehsu4x_.jpeg", + "about": "I generate Text with Unleashed.chat", + "encryptionSupported": True, + "cashuAccepted": True, + "nip90Params": {} + } + + nip89config = NIP89Config() + nip89config.DTAG = check_and_set_d_tag(identifier, name, dvm_config.PRIVATE_KEY, nip89info["image"]) + nip89config.CONTENT = json.dumps(nip89info) + + return TextGenerationUnleashedChat(name=name, dvm_config=dvm_config, nip89config=nip89config, admin_config=admin_config, ) + + +if __name__ == '__main__': + process_venv(TextGenerationUnleashedChat) diff --git a/nostr_dvm/tasks/texttospeech.py b/nostr_dvm/tasks/texttospeech.py index 2a1a071..e5e22d2 100644 --- a/nostr_dvm/tasks/texttospeech.py +++ b/nostr_dvm/tasks/texttospeech.py @@ -1,6 +1,10 @@ import json import os +from nostr_sdk import Kind + +from nostr_dvm.utils.nip88_utils import NIP88Config + os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" from pathlib import Path import urllib.request @@ -22,17 +26,18 @@ Outputs: Generated Audiofile class TextToSpeech(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_TEXT_TO_SPEECH + KIND: Kind = EventDefinitions.KIND_NIP90_TEXT_TO_SPEECH TASK: str = "text-to-speech" FIX_COST: float = 50 PER_UNIT_COST = 0.5 dependencies = [("nostr-dvm", "nostr-dvm"), ("TTS", "TTS==0.22.0")] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: diff --git a/nostr_dvm/tasks/translation_google.py b/nostr_dvm/tasks/translation_google.py index 896790f..e37764b 100644 --- a/nostr_dvm/tasks/translation_google.py +++ b/nostr_dvm/tasks/translation_google.py @@ -1,9 +1,13 @@ import json import os + +from nostr_sdk import Kind + from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.nostr_utils import get_referenced_event_by_id, get_event_by_id @@ -17,16 +21,17 @@ Params: -language The target language class TranslationGoogle(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_TRANSLATE_TEXT + KIND: Kind = EventDefinitions.KIND_NIP90_TRANSLATE_TEXT TASK: str = "translation" FIX_COST: float = 0 dependencies = [("nostr-dvm", "nostr-dvm"), ("translatepy", "translatepy==2.3")] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: diff --git a/nostr_dvm/tasks/translation_libretranslate.py b/nostr_dvm/tasks/translation_libretranslate.py index 7d4a99a..65d42d3 100644 --- a/nostr_dvm/tasks/translation_libretranslate.py +++ b/nostr_dvm/tasks/translation_libretranslate.py @@ -1,11 +1,13 @@ import json import os import requests +from nostr_sdk import Kind from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.nostr_utils import get_referenced_event_by_id, get_event_by_id @@ -21,14 +23,15 @@ Requires API key or self-hosted instance class TranslationLibre(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_TRANSLATE_TEXT + KIND: Kind = EventDefinitions.KIND_NIP90_TRANSLATE_TEXT TASK: str = "translation" FIX_COST: float = 0 - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, - admin_config: AdminConfig = None, options=None, task=None): + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, + admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options, task) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: @@ -106,6 +109,7 @@ class TranslationLibre(DVMTaskInterface): # playground or elsewhere def build_example(name, identifier, admin_config): dvm_config = build_default_config(identifier) + dvm_config.USE_OWN_VENV = False admin_config.LUD16 = dvm_config.LN_ADDRESS options = {'libre_end_point': os.getenv("LIBRE_TRANSLATE_ENDPOINT"), diff --git a/nostr_dvm/tasks/videogeneration_replicate_svd.py b/nostr_dvm/tasks/videogeneration_replicate_svd.py index 0dfd0ab..5624958 100644 --- a/nostr_dvm/tasks/videogeneration_replicate_svd.py +++ b/nostr_dvm/tasks/videogeneration_replicate_svd.py @@ -4,11 +4,13 @@ from io import BytesIO import requests import urllib.request from PIL import Image +from nostr_sdk import Kind from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.output_utils import upload_media_to_hoster from nostr_dvm.utils.zap_utils import get_price_per_sat @@ -23,16 +25,17 @@ Params: class VideoGenerationReplicateSVD(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_GENERATE_VIDEO + KIND: Kind = EventDefinitions.KIND_NIP90_GENERATE_VIDEO TASK: str = "image-to-video" FIX_COST: float = 120 dependencies = [("nostr-dvm", "nostr-dvm"), ("replicate", "replicate")] - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): dvm_config.SCRIPT = os.path.abspath(__file__) - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: diff --git a/nostr_dvm/tasks/videogeneration_svd.py b/nostr_dvm/tasks/videogeneration_svd.py index cbaadfe..2e4794c 100644 --- a/nostr_dvm/tasks/videogeneration_svd.py +++ b/nostr_dvm/tasks/videogeneration_svd.py @@ -1,10 +1,14 @@ import json +import os from multiprocessing.pool import ThreadPool +from nostr_sdk import Kind + from nostr_dvm.backends.nova_server.utils import check_server_status, send_request_to_server from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface, process_venv from nostr_dvm.utils.admin_utils import AdminConfig from nostr_dvm.utils.dvmconfig import DVMConfig, build_default_config +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config, check_and_set_d_tag from nostr_dvm.utils.definitions import EventDefinitions @@ -18,13 +22,14 @@ Outputs: An url to a video class VideoGenerationSVD(DVMTaskInterface): - KIND: int = EventDefinitions.KIND_NIP90_GENERATE_VIDEO + KIND: Kind = EventDefinitions.KIND_NIP90_GENERATE_VIDEO TASK: str = "image-to-video" FIX_COST: float = 120 - def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, + def __init__(self, name, dvm_config: DVMConfig, nip89config: NIP89Config, nip88config: NIP88Config = None, admin_config: AdminConfig = None, options=None): - super().__init__(name, dvm_config, nip89config, admin_config, options) + super().__init__(name=name, dvm_config=dvm_config, nip89config=nip89config, nip88config=nip88config, + admin_config=admin_config, options=options) def is_input_supported(self, tags, client=None, dvm_config=None): for tag in tags: diff --git a/nostr_dvm/utils/admin_utils.py b/nostr_dvm/utils/admin_utils.py index 3b32a16..ec2b3ac 100644 --- a/nostr_dvm/utils/admin_utils.py +++ b/nostr_dvm/utils/admin_utils.py @@ -5,23 +5,28 @@ from nostr_sdk import Keys, PublicKey, Client from nostr_dvm.utils.database_utils import get_from_sql_table, list_db, delete_from_sql_table, update_sql_table, \ get_or_add_user, clean_db from nostr_dvm.utils.dvmconfig import DVMConfig -from nostr_dvm.utils.nip89_utils import nip89_announce_tasks, fetch_nip89_paramters_for_deletion +from nostr_dvm.utils.nip88_utils import nip88_announce_tier, fetch_nip88_parameters_for_deletion, fetch_nip88_event, \ + check_and_set_tiereventid_nip88 +from nostr_dvm.utils.nip89_utils import nip89_announce_tasks, fetch_nip89_parameters_for_deletion from nostr_dvm.utils.nostr_utils import update_profile class AdminConfig: REBROADCAST_NIP89: bool = False + REBROADCAST_NIP88: bool = False UPDATE_PROFILE: bool = False DELETE_NIP89: bool = False + DELETE_NIP88: bool = False + FETCH_NIP88: bool = False WHITELISTUSER: bool = False UNWHITELISTUSER: bool = False BLACKLISTUSER: bool = False DELETEUSER: bool = False LISTDATABASE: bool = False ClEANDB: bool = False + INDEX: str = "1" - USERNPUB: str = "" - LUD16: str = "" + USERNPUBS: list = [] EVENTID: str = "" PRIVKEY: str = "" @@ -37,7 +42,7 @@ def admin_make_database_updates(adminconfig: AdminConfig = None, dvmconfig: DVMC if (( adminconfig.WHITELISTUSER is True or adminconfig.UNWHITELISTUSER is True or adminconfig.BLACKLISTUSER is True or adminconfig.DELETEUSER is True) - and adminconfig.USERNPUB == ""): + and adminconfig.USERNPUBS == []): return if adminconfig.UPDATE_PROFILE and (dvmconfig.NIP89 is None): @@ -48,27 +53,28 @@ def admin_make_database_updates(adminconfig: AdminConfig = None, dvmconfig: DVMC db = dvmconfig.DB - if str(adminconfig.USERNPUB).startswith("npub"): - publickey = PublicKey.from_bech32(adminconfig.USERNPUB).to_hex() - else: - publickey = adminconfig.USERNPUB + for npub in adminconfig.USERNPUBS: + if str(npub).startswith("npub"): + publickey = PublicKey.from_bech32(npub).to_hex() + else: + publickey = npub - if adminconfig.WHITELISTUSER: - 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)) + if adminconfig.WHITELISTUSER: + 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.subscribed) + user = get_from_sql_table(db, publickey) + print(str(user.name) + " is whitelisted: " + str(user.iswhitelisted)) - if adminconfig.UNWHITELISTUSER: - user = get_from_sql_table(db, publickey) - update_sql_table(db, user.npub, user.balance, False, False, user.nip05, user.lud16, user.name, user.lastactive) + if adminconfig.UNWHITELISTUSER: + user = get_from_sql_table(db, publickey) + update_sql_table(db, user.npub, user.balance, False, False, user.nip05, user.lud16, user.name, user.lastactive, user.subscribed) - if adminconfig.BLACKLISTUSER: - user = get_from_sql_table(db, publickey) - update_sql_table(db, user.npub, user.balance, False, True, user.nip05, user.lud16, user.name, user.lastactive) + if adminconfig.BLACKLISTUSER: + user = get_from_sql_table(db, publickey) + update_sql_table(db, user.npub, user.balance, False, True, user.nip05, user.lud16, user.name, user.lastactive, user.subscribed) - if adminconfig.DELETEUSER: - delete_from_sql_table(db, publickey) + if adminconfig.DELETEUSER: + delete_from_sql_table(db, publickey) if adminconfig.ClEANDB: clean_db(db) @@ -79,11 +85,27 @@ def admin_make_database_updates(adminconfig: AdminConfig = None, dvmconfig: DVMC if adminconfig.REBROADCAST_NIP89: nip89_announce_tasks(dvmconfig, client=client) + if adminconfig.REBROADCAST_NIP88: + annotier_id = nip88_announce_tier(dvmconfig, client=client) + check_and_set_tiereventid_nip88(dvmconfig.IDENTIFIER, adminconfig.INDEX, annotier_id.to_hex()) + if adminconfig.DELETE_NIP89: event_id = adminconfig.EVENTID - keys = Keys.from_sk_str( + keys = Keys.parse( adminconfig.PRIVKEY) # Private key from sender of Event (e.g. the key of an nip89 announcement you want to delete) - fetch_nip89_paramters_for_deletion(keys, event_id, client, dvmconfig) + fetch_nip89_parameters_for_deletion(keys, event_id, client, dvmconfig) + + if adminconfig.DELETE_NIP88: + event_id = adminconfig.EVENTID + keys = Keys.parse( + adminconfig.PRIVKEY) # Private key from sender of Event (e.g. the key of an nip89 announcement you want to delete) + fetch_nip88_parameters_for_deletion(keys, event_id, client, dvmconfig) + + if adminconfig.FETCH_NIP88: + event_id = adminconfig.EVENTID + keys = Keys.parse( + adminconfig.PRIVKEY) + fetch_nip88_event(keys, event_id, client, dvmconfig) if adminconfig.UPDATE_PROFILE: - update_profile(dvmconfig, client, lud16=adminconfig.LUD16) + update_profile(dvmconfig, client, lud16=dvmconfig.LN_ADDRESS) diff --git a/nostr_dvm/utils/backend_utils.py b/nostr_dvm/utils/backend_utils.py index cb69d59..ac28697 100644 --- a/nostr_dvm/utils/backend_utils.py +++ b/nostr_dvm/utils/backend_utils.py @@ -92,7 +92,7 @@ def get_task(event, client, dvm_config): else: for dvm in dvm_config.SUPPORTED_DVMS: - if dvm.KIND == event.kind(): + if dvm.KIND.as_u64() == event.kind().as_u64(): return dvm.TASK except Exception as e: print("Get task: " + str(e)) @@ -111,11 +111,11 @@ def is_input_supported_generic(tags, client, dvm_config) -> bool: else: input_value = tag.as_vec()[1] input_type = tag.as_vec()[2] - if input_type == "event": - evt = get_event_by_id(input_value, client=client, config=dvm_config) - if evt is None: - print("Event not found") - return False + #if input_type == "event": + # evt = get_event_by_id(input_value, client=client, config=dvm_config) + # if evt is None: + # print("Event not found") + # return False # TODO check_url_is_readable might be more relevant per task in the future # if input_type == 'url' and check_url_is_readable(input_value) is None: # print("Url not readable / supported") diff --git a/nostr_dvm/utils/database_utils.py b/nostr_dvm/utils/database_utils.py index 16e9250..4662ddd 100644 --- a/nostr_dvm/utils/database_utils.py +++ b/nostr_dvm/utils/database_utils.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import timedelta from logging import Filter -from nostr_sdk import Timestamp, Keys, PublicKey, EventBuilder, Filter +from nostr_sdk import Timestamp, Keys, PublicKey, EventBuilder, Filter, Kind from nostr_dvm.utils.nostr_utils import send_event @@ -21,6 +21,7 @@ class User: nip05: str lud16: str lastactive: int + subscribed: int def create_sql_table(db): @@ -40,7 +41,8 @@ def create_sql_table(db): nip05 text, lud16 text, name text, - lastactive integer + lastactive integer, + subscribed integer ); """) cur.execute("SELECT name FROM sqlite_master") con.close() @@ -53,29 +55,29 @@ def add_sql_table_column(db): try: con = sqlite3.connect(db) cur = con.cursor() - cur.execute(""" ALTER TABLE users ADD COLUMN lastactive 'integer' """) + cur.execute(""" ALTER TABLE users ADD COLUMN subscribed 'integer' """) con.close() except Error as e: print(e) -def add_to_sql_table(db, npub, sats, iswhitelisted, isblacklisted, nip05, lud16, name, lastactive): +def add_to_sql_table(db, npub, sats, iswhitelisted, isblacklisted, nip05, lud16, name, lastactive, subscribed): try: con = sqlite3.connect(db) cur = con.cursor() - data = (npub, sats, iswhitelisted, isblacklisted, nip05, lud16, name, lastactive) - cur.execute("INSERT or IGNORE INTO users VALUES(?, ?, ?, ?, ?, ?, ?, ?)", data) + data = (npub, sats, iswhitelisted, isblacklisted, nip05, lud16, name, lastactive, subscribed) + cur.execute("INSERT or IGNORE INTO users VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)", data) con.commit() con.close() except Error as e: print("Error when Adding to DB: " + str(e)) -def update_sql_table(db, npub, balance, iswhitelisted, isblacklisted, nip05, lud16, name, lastactive): +def update_sql_table(db, npub, balance, iswhitelisted, isblacklisted, nip05, lud16, name, lastactive, subscribed): try: con = sqlite3.connect(db) cur = con.cursor() - data = (balance, iswhitelisted, isblacklisted, nip05, lud16, name, lastactive, npub) + data = (balance, iswhitelisted, isblacklisted, nip05, lud16, name, lastactive, subscribed, npub) cur.execute(""" UPDATE users SET sats = ? , @@ -84,7 +86,8 @@ def update_sql_table(db, npub, balance, iswhitelisted, isblacklisted, nip05, lud nip05 = ? , lud16 = ? , name = ? , - lastactive = ? + lastactive = ?, + subscribed = ? WHERE npub = ?""", data) con.commit() con.close() @@ -102,6 +105,12 @@ def get_from_sql_table(db, npub): if row is None: return None else: + + if len(row) < 9: + add_sql_table_column(db) + # Migrate
 + + user = User user.npub = row[0] user.balance = row[1] @@ -111,6 +120,9 @@ def get_from_sql_table(db, npub): user.lud16 = row[5] user.name = row[6] user.lastactive = row[7] + user.subscribed = row[8] + if user.subscribed is None: + user.subscribed = 0 return user @@ -157,41 +169,70 @@ def list_db(db): print(e) -def update_user_balance(db, npub, additional_sats, client, config): +def update_user_balance(db, npub, additional_sats, client, config, giftwrap=False): user = get_from_sql_table(db, npub) if user is None: 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()) + nip05, lud16, name, Timestamp.now().as_secs(), 0) print("Adding User: " + npub + " (" + npub + ")") else: 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()) + Timestamp.now().as_secs(), user.subscribed) print("Updated user balance for: " + str(user.name) + - " Zap amount: " + str(additional_sats) + " Sats. New balance: " + str(new_balance) +" Sats") + " 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) + keys = Keys.parse(config.PRIVATE_KEY) + # time.sleep(1.0) - message = ("Added " + str(additional_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.encrypted_direct_msg(keys, PublicKey.from_hex(npub), message, + if giftwrap: + client.send_sealed_msg(PublicKey.parse(npub), message, None) + else: + + + evt = EventBuilder.encrypted_direct_msg(keys, PublicKey.parse(npub), message, None).to_event(keys) - send_event(evt, client=client, dvm_config=config) + send_event(evt, client=client, dvm_config=config) -def get_or_add_user(db, npub, client, config, update=False): +def update_user_subscription(npub, subscribed_until, client, dvm_config): + user = get_from_sql_table(dvm_config.DB, npub) + if user is None: + name, nip05, lud16 = fetch_user_metadata(npub, client) + add_to_sql_table(dvm_config.DB, npub, dvm_config.NEW_USER_BALANCE, False, False, + nip05, lud16, name, Timestamp.now().as_secs(), 0) + print("Adding User: " + npub + " (" + npub + ")") + else: + user = get_from_sql_table(dvm_config.DB, npub) + + update_sql_table(dvm_config.DB, npub, user.balance, user.iswhitelisted, user.isblacklisted, user.nip05, + user.lud16, + user.name, + Timestamp.now().as_secs(), subscribed_until) + print("Updated user subscription for: " + str(user.name)) + + + +def get_or_add_user(db, npub, client, config, update=False, skip_meta = False): user = get_from_sql_table(db, npub) if user is None: try: - name, nip05, lud16 = fetch_user_metadata(npub, client) + if skip_meta: + name = npub + nip05 = "" + lud16 = "" + else: + 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()) + lud16, name, Timestamp.now().as_secs(), 0) user = get_from_sql_table(db, npub) return user except Exception as e: @@ -201,7 +242,7 @@ def get_or_add_user(db, npub, client, config, update=False): name, nip05, lud16 = fetch_user_metadata(npub, client) print("Updating User: " + npub + " (" + npub + ")") update_sql_table(db, user.npub, user.balance, user.iswhitelisted, user.isblacklisted, nip05, - lud16, name, Timestamp.now().as_secs()) + lud16, name, Timestamp.now().as_secs(), user.subscribed) user = get_from_sql_table(db, npub) return user except Exception as e: @@ -214,9 +255,9 @@ def fetch_user_metadata(npub, client): name = "" nip05 = "" lud16 = "" - pk = PublicKey.from_hex(npub) + pk = PublicKey.parse(npub) print(f"\nGetting profile metadata for {pk.to_bech32()}...") - profile_filter = Filter().kind(0).author(pk).limit(1) + profile_filter = Filter().kind(Kind(0)).author(pk).limit(1) events = client.get_events_of([profile_filter], timedelta(seconds=1)) if len(events) > 0: latest_entry = events[0] diff --git a/nostr_dvm/utils/definitions.py b/nostr_dvm/utils/definitions.py index 3397307..ee6aa50 100644 --- a/nostr_dvm/utils/definitions.py +++ b/nostr_dvm/utils/definitions.py @@ -1,39 +1,53 @@ import os from dataclasses import dataclass -from nostr_sdk import Event +from nostr_sdk import Event, Kind class EventDefinitions: - KIND_DM = 4 - KIND_ZAP = 9735 - KIND_ANNOUNCEMENT = 31990 - KIND_NIP94_METADATA = 1063 - KIND_FEEDBACK = 7000 - KIND_NIP90_EXTRACT_TEXT = 5000 - KIND_NIP90_RESULT_EXTRACT_TEXT = KIND_NIP90_EXTRACT_TEXT + 1000 - KIND_NIP90_SUMMARIZE_TEXT = 5001 - KIND_NIP90_RESULT_SUMMARIZE_TEXT = KIND_NIP90_SUMMARIZE_TEXT + 1000 - KIND_NIP90_TRANSLATE_TEXT = 5002 - KIND_NIP90_RESULT_TRANSLATE_TEXT = KIND_NIP90_TRANSLATE_TEXT + 1000 - KIND_NIP90_GENERATE_TEXT = 5050 - KIND_NIP90_RESULT_GENERATE_TEXT = KIND_NIP90_GENERATE_TEXT + 1000 - KIND_NIP90_GENERATE_IMAGE = 5100 - KIND_NIP90_RESULT_GENERATE_IMAGE = KIND_NIP90_GENERATE_IMAGE + 1000 - KIND_NIP90_CONVERT_VIDEO = 5200 - KIND_NIP90_RESULT_CONVERT_VIDEO = KIND_NIP90_CONVERT_VIDEO + 1000 - KIND_NIP90_GENERATE_VIDEO = 5202 - KIND_NIP90_TEXT_TO_SPEECH = 5250 - KIND_NIP90_RESULT_TEXT_TO_SPEECH = KIND_NIP90_TEXT_TO_SPEECH + 1000 - KIND_NIP90_RESULT_GENERATE_VIDEO = KIND_NIP90_GENERATE_VIDEO + 1000 - KIND_NIP90_CONTENT_DISCOVERY = 5300 - KIND_NIP90_RESULT_CONTENT_DISCOVERY = KIND_NIP90_CONTENT_DISCOVERY + 1000 - KIND_NIP90_PEOPLE_DISCOVERY = 5301 - KIND_NIP90_RESULT_PEOPLE_DISCOVERY = KIND_NIP90_PEOPLE_DISCOVERY + 1000 - KIND_NIP90_CONTENT_SEARCH = 5302 - KIND_NIP90_RESULTS_CONTENT_SEARCH = KIND_NIP90_CONTENT_SEARCH + 1000 - KIND_NIP90_GENERIC = 5999 - KIND_NIP90_RESULT_GENERIC = KIND_NIP90_GENERIC + 1000 + KIND_NOTE = Kind(1) + KIND_DM = Kind(4) + KIND_REPOST = Kind(6) + KIND_REACTION = Kind(7) + KIND_ZAP = Kind(9735) + KIND_ANNOUNCEMENT = Kind(31990) + KIND_NIP94_METADATA = Kind(1063) + KIND_FEEDBACK = Kind(7000) + KIND_NIP90_EXTRACT_TEXT = Kind(5000) + KIND_NIP90_RESULT_EXTRACT_TEXT = Kind(6000) + KIND_NIP90_SUMMARIZE_TEXT = Kind(5001) + KIND_NIP90_RESULT_SUMMARIZE_TEXT = Kind(6001) + KIND_NIP90_TRANSLATE_TEXT = Kind(5002) + KIND_NIP90_RESULT_TRANSLATE_TEXT = Kind(6002) + KIND_NIP90_GENERATE_TEXT = Kind(5050) + KIND_NIP90_RESULT_GENERATE_TEXT = Kind(6050) + KIND_NIP90_GENERATE_IMAGE = Kind(5100) + KIND_NIP90_RESULT_GENERATE_IMAGE = Kind(6100) + KIND_NIP90_CONVERT_VIDEO = Kind(5200) + KIND_NIP90_RESULT_CONVERT_VIDEO = Kind(6200) + KIND_NIP90_GENERATE_VIDEO = Kind(5202) + KIND_NIP90_RESULT_GENERATE_VIDEO =Kind(6202) + KIND_NIP90_TEXT_TO_SPEECH = Kind(5250) + KIND_NIP90_RESULT_TEXT_TO_SPEECH = Kind(5650) + KIND_NIP90_CONTENT_DISCOVERY = Kind(5300) + KIND_NIP90_RESULT_CONTENT_DISCOVERY = Kind(6300) + KIND_NIP90_PEOPLE_DISCOVERY = Kind(5301) + KIND_NIP90_RESULT_PEOPLE_DISCOVERY = Kind(6301) + KIND_NIP90_CONTENT_SEARCH = Kind(5302) + KIND_NIP90_RESULTS_CONTENT_SEARCH = Kind(6302) + KIND_NIP90_USER_SEARCH = Kind(5303) + KIND_NIP90_RESULTS_USER_SEARCH = Kind(6303) + KIND_NIP90_DVM_SUBSCRIPTION = Kind(5906) + KIND_NIP90_RESULT_DVM_SUBSCRIPTION = Kind(6906) + + KIND_NIP90_GENERIC = Kind(5999) + KIND_NIP90_RESULT_GENERIC = Kind(6999) + + KIND_NIP88_SUBSCRIBE_EVENT = Kind(7001) + KIND_NIP88_STOP_SUBSCRIPTION_EVENT = Kind(7002) + KIND_NIP88_PAYMENT_RECIPE = Kind(7003) + KIND_NIP88_TIER_EVENT = Kind(37001) + ANY_RESULT = [KIND_NIP90_RESULT_EXTRACT_TEXT, KIND_NIP90_RESULT_SUMMARIZE_TEXT, KIND_NIP90_RESULT_TRANSLATE_TEXT, diff --git a/nostr_dvm/utils/dvmconfig.py b/nostr_dvm/utils/dvmconfig.py index 46a2d29..f926225 100644 --- a/nostr_dvm/utils/dvmconfig.py +++ b/nostr_dvm/utils/dvmconfig.py @@ -2,6 +2,7 @@ import os from nostr_sdk import Keys +from nostr_dvm.utils.nip88_utils import NIP88Config from nostr_dvm.utils.nip89_utils import NIP89Config from nostr_dvm.utils.nostr_utils import check_and_set_private_key from nostr_dvm.utils.output_utils import PostProcessFunctionType @@ -15,32 +16,35 @@ class DVMConfig: FIX_COST: float = None PER_UNIT_COST: float = None - RELAY_LIST = ["wss://relay.damus.io", "wss://nostr-pub.wellorder.net", "wss://nos.lol", "wss://nostr.wine", + RELAY_LIST = ["wss://relay.damus.io", "wss://nos.lol", "wss://nostr.wine", "wss://nostr.mom", "wss://nostr.oxtr.dev", "wss://relay.nostr.bg", "wss://relay.f7z.io", "wss://pablof7z.nostr1.com", "wss://relay.nostr.net", "wss://140.f7z.io", - "wss://relay.snort.social", "wss://offchain.pub/", "wss://relay.nostr.band"] + ] RELAY_TIMEOUT = 5 EXTERNAL_POST_PROCESS_TYPE = PostProcessFunctionType.NONE # Leave this on None, except the DVM is external - LNBITS_INVOICE_KEY = '' # Will all automatically generated by default, or read from .env + LNBITS_INVOICE_KEY = '' # Will all automatically generated by default, or read from .env LNBITS_ADMIN_KEY = '' # In order to pay invoices, e.g. from the bot to DVMs, or reimburse users. LNBITS_URL = 'https://lnbits.com' LN_ADDRESS = '' SCRIPT = '' IDENTIFIER = '' - USE_OWN_VENV = True # Make an own venv for each dvm's process function.Disable if you want to install packages into main venv. Only recommended if you dont want to run dvms with different dependency versions + USE_OWN_VENV = True # Make an own venv for each dvm's process function.Disable if you want to install packages into main venv. Only recommended if you dont want to run dvms with different dependency versions DB: str NEW_USER_BALANCE: int = 0 # Free credits for new users + SUBSCRIPTION_MANAGEMENT = 'https://noogle.lol/discovery' + NIP88: NIP88Config NIP89: NIP89Config SEND_FEEDBACK_EVENTS = True SHOW_RESULT_BEFORE_PAYMENT: bool = False # if this is true show results even when not paid right after autoprocess + SCHEDULE_UPDATES_SECONDS = 0 def build_default_config(identifier): dvm_config = DVMConfig() dvm_config.PRIVATE_KEY = check_and_set_private_key(identifier) dvm_config.IDENTIFIER = identifier - npub = Keys.from_sk_str(dvm_config.PRIVATE_KEY).public_key().to_bech32() + npub = Keys.parse(dvm_config.PRIVATE_KEY).public_key().to_bech32() invoice_key, admin_key, wallet_id, user_id, lnaddress = check_and_set_ln_bits_keys(identifier, npub) dvm_config.LNBITS_INVOICE_KEY = invoice_key dvm_config.LNBITS_ADMIN_KEY = admin_key # The dvm might pay failed jobs back diff --git a/nostr_dvm/utils/external_dvm_utils.py b/nostr_dvm/utils/external_dvm_utils.py index d123e5c..d9e227e 100644 --- a/nostr_dvm/utils/external_dvm_utils.py +++ b/nostr_dvm/utils/external_dvm_utils.py @@ -1,7 +1,7 @@ import json from datetime import timedelta -from nostr_sdk import PublicKey, Options, Keys, Client, ClientSigner +from nostr_sdk import PublicKey, Options, Keys, Client, NostrSigner from nostr_dvm.interfaces.dvmtaskinterface import DVMTaskInterface from nostr_dvm.utils.dvmconfig import DVMConfig @@ -19,8 +19,8 @@ def build_external_dvm(pubkey, task, kind, fix_cost, per_unit_cost, config, opts = (Options().wait_for_send(True).send_timeout(timedelta(seconds=config.RELAY_TIMEOUT)) .skip_disconnected_relays(True)) - keys = Keys.from_sk_str(config.PRIVATE_KEY) - signer = ClientSigner.keys(keys) + keys = Keys.parse(config.PRIVATE_KEY) + signer = NostrSigner.keys(keys) client = Client.with_opts(signer, opts) diff --git a/nostr_dvm/utils/mediasource_utils.py b/nostr_dvm/utils/mediasource_utils.py index 797c015..11bf758 100644 --- a/nostr_dvm/utils/mediasource_utils.py +++ b/nostr_dvm/utils/mediasource_utils.py @@ -14,15 +14,19 @@ def input_data_file_duration(event, dvm_config, client, start=0, end=0): # print("[" + dvm_config.NIP89.NAME + "] Getting Duration of the Media file..") input_value = "" input_type = "" + count = 0 for tag in event.tags(): if tag.as_vec()[0] == 'i': input_value = tag.as_vec()[1] input_type = tag.as_vec()[2] + count = count+1 if input_type == "text": return len(input_value) if input_type == "event": # NIP94 event + if count > 1: + return 1 # we ignore length for multiple event inputs for now evt = get_event_by_id(input_value, client=client, config=dvm_config) if evt is not None: input_value, input_type = check_nip94_event_for_media(evt, input_value, input_type) diff --git a/nostr_dvm/utils/nip88_utils.py b/nostr_dvm/utils/nip88_utils.py new file mode 100644 index 0000000..188cadc --- /dev/null +++ b/nostr_dvm/utils/nip88_utils.py @@ -0,0 +1,218 @@ +import os +from datetime import timedelta +from hashlib import sha256 +from pathlib import Path + +import dotenv +from nostr_sdk import Filter, Tag, Keys, EventBuilder, Client, EventId, PublicKey, Event, Timestamp, SingleLetterTag, \ + Alphabet +from nostr_sdk.nostr_sdk import Duration + +from nostr_dvm.utils import definitions +from nostr_dvm.utils.definitions import EventDefinitions +from nostr_dvm.utils.nostr_utils import send_event + + +class NIP88Config: + DTAG: str = "" + TITLE: str = "" + CONTENT: str = "" + IMAGE: str = "" + TIER_EVENT: str = "" + PERK1DESC: str = "" + PERK2DESC: str = "" + PERK3DESC: str = "" + PERK4DESC: str = "" + PAYMENT_VERIFIER_PUBKEY: str = "" + + AMOUNT_DAILY: int = None + AMOUNT_MONTHLY: int = None + AMOUNT_YEARLY: int = None + + +def nip88_create_d_tag(name, pubkey, image): + key_str = str(name + image + pubkey) + d_tag = sha256(key_str.encode('utf-8')).hexdigest()[:16] + return d_tag + + +def fetch_nip88_parameters_for_deletion(keys, eventid, client, dvmconfig): + idfilter = Filter().id(EventId.from_hex(eventid)).limit(1) + nip88events = client.get_events_of([idfilter], timedelta(seconds=dvmconfig.RELAY_TIMEOUT)) + d_tag = "" + if len(nip88events) == 0: + print("Event not found. Potentially gone.") + + for event in nip88events: + print(event.as_json()) + for tag in event.tags(): + if tag.as_vec()[0] == "d": + d_tag = tag.as_vec()[1] + if d_tag == "": + print("No dtag found") + return + + if event.author().to_hex() == keys.public_key().to_hex(): + nip88_delete_announcement(event.id().to_hex(), keys, d_tag, client, dvmconfig) + print("NIP88 announcement deleted from known relays!") + else: + print("Privatekey does not belong to event") + + +def fetch_nip88_event(keys, eventid, client, dvmconfig): + idfilter = Filter().id(EventId.parse(eventid)).limit(1) + nip88events = client.get_events_of([idfilter], timedelta(seconds=dvmconfig.RELAY_TIMEOUT)) + d_tag = "" + if len(nip88events) == 0: + print("Event not found. Potentially gone.") + + for event in nip88events: + + for tag in event.tags(): + if tag.as_vec()[0] == "d": + d_tag = tag.as_vec()[1] + if d_tag == "": + print("No dtag found") + return + + if event.author().to_hex() == keys.public_key().to_hex(): + print(event.as_json()) + else: + print("Privatekey does not belong to event") + + +def nip88_delete_announcement(eid: str, keys: Keys, dtag: str, client: Client, config): + e_tag = Tag.parse(["e", eid]) + a_tag = Tag.parse( + ["a", str(EventDefinitions.KIND_NIP88_TIER_EVENT) + ":" + keys.public_key().to_hex() + ":" + dtag]) + event = EventBuilder(5, "", [e_tag, a_tag]).to_event(keys) + send_event(event, client, config) + + +def nip88_has_active_subscription(user: PublicKey, tiereventdtag, client: Client, receiver_public_key_hex, checkCanceled = True): + subscription_status = { + "isActive": False, + "validUntil": 0, + "subscriptionId": "", + "expires": False, + } + + subscriptionfilter = Filter().kind(definitions.EventDefinitions.KIND_NIP88_PAYMENT_RECIPE).pubkey( + PublicKey.parse(receiver_public_key_hex)).custom_tag(SingleLetterTag.uppercase(Alphabet.P), + [user.to_hex()]).limit(1) + evts = client.get_events_of([subscriptionfilter], timedelta(seconds=3)) + if len(evts) > 0: + print(evts[0].as_json()) + matchesdtag = False + for tag in evts[0].tags(): + if tag.as_vec()[0] == "valid": + subscription_status["validUntil"] = int(tag.as_vec()[2]) + elif tag.as_vec()[0] == "e": + subscription_status["subscriptionId"] = tag.as_vec()[1] + elif tag.as_vec()[0] == "tier": + if tag.as_vec()[1] == tiereventdtag: + matchesdtag = True + + if (subscription_status["validUntil"] > Timestamp.now().as_secs()) & matchesdtag: + subscription_status["isActive"] = True + + if subscription_status["isActive"] and checkCanceled: + # if subscription seems active, check if it has been canceled, and if so mark it as expiring. + cancel_filter = Filter().kind(EventDefinitions.KIND_NIP88_STOP_SUBSCRIPTION_EVENT).author( + user).pubkey(PublicKey.parse(receiver_public_key_hex)).event( + EventId.parse(subscription_status["subscriptionId"])).limit(1) + cancel_events = client.get_events_of([cancel_filter], timedelta(seconds=3)) + if len(cancel_events) > 0: + if cancel_events[0].created_at().as_secs() > evts[0].created_at().as_secs(): + subscription_status["expires"] = True + + return subscription_status + + +def nip88_announce_tier(dvm_config, client): + title_tag = Tag.parse(["title", str(dvm_config.NIP88.TITLE)]) + image_tag = Tag.parse(["image", str(dvm_config.NIP88.IMAGE)]) + d_tag = Tag.parse(["d", dvm_config.NIP88.DTAG]) + + # zap splits. Feel free to change this for your DVM + + # By default, 80% of subscription go to the current's DVM lightning address (make sure to update profile for it to work) + # 5% go to NostrDVM developers + # 5% go to NostrSDK developers + # 10% optionally go to clients that support this subscription DVM + zaptag1 = Tag.parse(["zap", dvm_config.PUBLIC_KEY, "wss://damus.io", "16"]) + zaptag2 = Tag.parse(["zap", "npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8", "wss://damus.io", "1"]) # NostrDVM + zaptag3 = Tag.parse(["zap", "npub1drvpzev3syqt0kjrls50050uzf25gehpz9vgdw08hvex7e0vgfeq0eseet", "wss://damus.io", "1"]) # NostrSDK + zaptag4 = Tag.parse(["zap", "", "wss://damus.io", "2"]) # Client might use this for splits + p_tag = Tag.parse(["p", dvm_config.NIP88.PAYMENT_VERIFIER_PUBKEY]) + + tags = [title_tag, image_tag, zaptag1, zaptag2, zaptag3, zaptag4, d_tag, p_tag] + + if dvm_config.NIP88.AMOUNT_DAILY is not None: + amount_tag = Tag.parse(["amount", str(dvm_config.NIP88.AMOUNT_DAILY * 1000), "msats", "daily"]) + tags.append(amount_tag) + + if dvm_config.NIP88.AMOUNT_MONTHLY is not None: + amount_tag = Tag.parse(["amount", str(dvm_config.NIP88.AMOUNT_MONTHLY * 1000), "msats", "monthly"]) + tags.append(amount_tag) + + if dvm_config.NIP88.AMOUNT_YEARLY is not None: + amount_tag = Tag.parse(["amount", str(dvm_config.NIP88.AMOUNT_YEARLY * 1000), "msats", "yearly"]) + tags.append(amount_tag) + + if dvm_config.NIP88.PERK1DESC != "": + perk_tag = Tag.parse(["perk", str(dvm_config.NIP88.PERK1DESC)]) + tags.append(perk_tag) + if dvm_config.NIP88.PERK2DESC != "": + perk_tag = Tag.parse(["perk", str(dvm_config.NIP88.PERK2DESC)]) + tags.append(perk_tag) + if dvm_config.NIP88.PERK3DESC != "": + perk_tag = Tag.parse(["perk", str(dvm_config.NIP88.PERK3DESC)]) + tags.append(perk_tag) + if dvm_config.NIP88.PERK4DESC != "": + perk_tag = Tag.parse(["perk", str(dvm_config.NIP88.PERK4DESC)]) + tags.append(perk_tag) + + keys = Keys.parse(dvm_config.NIP89.PK) + content = dvm_config.NIP88.CONTENT + event = EventBuilder(EventDefinitions.KIND_NIP88_TIER_EVENT, content, tags).to_event(keys) + annotier_id = send_event(event, client=client, dvm_config=dvm_config) + + print("Announced NIP 88 Tier for " + dvm_config.NIP89.NAME) + return annotier_id + + # Relay and payment-verification + + +# ["r", "wss://my-subscribers-only-relay.com"], +# ["p", ""], + +def check_and_set_d_tag_nip88(identifier, name, pk, imageurl): + if not os.getenv("NIP88_DTAG_" + identifier.upper()): + new_dtag = nip88_create_d_tag(name, Keys.parse(pk).public_key().to_hex(), + imageurl) + nip88_add_dtag_to_env_file("NIP88_DTAG_" + identifier.upper(), new_dtag) + print("Some new dtag:" + new_dtag) + return new_dtag + else: + return os.getenv("NIP88_DTAG_" + identifier.upper()) + + +def check_and_set_tiereventid_nip88(identifier, index="1", eventid=None): + if eventid is None: + if not os.getenv("NIP88_TIEREVENT_" + index + identifier.upper()): + print("No Tier Event ID set") + return None + else: + return os.getenv("NIP88_TIEREVENT_" + index + identifier.upper()) + else: + nip88_add_dtag_to_env_file("NIP88_TIEREVENT_" + index + identifier.upper(), eventid) + return eventid + + +def nip88_add_dtag_to_env_file(dtag, oskey): + env_path = Path('.env') + if env_path.is_file(): + print(f'loading environment from {env_path.resolve()}') + dotenv.load_dotenv(env_path, verbose=True, override=True) + dotenv.set_key(env_path, dtag, oskey) diff --git a/nostr_dvm/utils/nip89_utils.py b/nostr_dvm/utils/nip89_utils.py index 37d5f98..2fa5630 100644 --- a/nostr_dvm/utils/nip89_utils.py +++ b/nostr_dvm/utils/nip89_utils.py @@ -4,7 +4,7 @@ from hashlib import sha256 from pathlib import Path import dotenv -from nostr_sdk import Tag, Keys, EventBuilder, Filter, Alphabet, PublicKey, Client, EventId +from nostr_sdk import Tag, Keys, EventBuilder, Filter, Alphabet, PublicKey, Client, EventId, SingleLetterTag, Kind from nostr_dvm.utils.definitions import EventDefinitions from nostr_dvm.utils.nostr_utils import send_event @@ -25,16 +25,16 @@ def nip89_create_d_tag(name, pubkey, image): def nip89_announce_tasks(dvm_config, client): - k_tag = Tag.parse(["k", str(dvm_config.NIP89.KIND)]) + k_tag = Tag.parse(["k", str(dvm_config.NIP89.KIND.as_u64())]) d_tag = Tag.parse(["d", dvm_config.NIP89.DTAG]) - keys = Keys.from_sk_str(dvm_config.NIP89.PK) + keys = Keys.parse(dvm_config.NIP89.PK) content = dvm_config.NIP89.CONTENT event = EventBuilder(EventDefinitions.KIND_ANNOUNCEMENT, content, [k_tag, d_tag]).to_event(keys) send_event(event, client=client, dvm_config=dvm_config) print("Announced NIP 89 for " + dvm_config.NIP89.NAME) -def fetch_nip89_paramters_for_deletion(keys, eventid, client, dvmconfig): +def fetch_nip89_parameters_for_deletion(keys, eventid, client, dvmconfig): idfilter = Filter().id(EventId.from_hex(eventid)).limit(1) nip89events = client.get_events_of([idfilter], timedelta(seconds=dvmconfig.RELAY_TIMEOUT)) d_tag = "" @@ -59,8 +59,8 @@ def fetch_nip89_paramters_for_deletion(keys, eventid, client, dvmconfig): def nip89_delete_announcement(eid: str, keys: Keys, dtag: str, client: Client, config): e_tag = Tag.parse(["e", eid]) - a_tag = Tag.parse(["a", str(EventDefinitions.KIND_ANNOUNCEMENT) + ":" + keys.public_key().to_hex() + ":" + dtag]) - event = EventBuilder(5, "", [e_tag, a_tag]).to_event(keys) + a_tag = Tag.parse(["a", str(EventDefinitions.KIND_ANNOUNCEMENT.as_u64()) + ":" + keys.public_key().to_hex() + ":" + dtag]) + event = EventBuilder(Kind(5), "", [e_tag, a_tag]).to_event(keys) send_event(event, client, config) @@ -69,19 +69,18 @@ def nip89_fetch_all_dvms(client): for i in range(5000, 5999): ktags.append(str(i)) - filter = Filter().kind(EventDefinitions.KIND_ANNOUNCEMENT).custom_tag(Alphabet.K, ktags) + filter = Filter().kind(EventDefinitions.KIND_ANNOUNCEMENT).custom_tag(SingleLetterTag.lowercase(Alphabet.K), ktags) events = client.get_events_of([filter], timedelta(seconds=5)) for event in events: print(event.as_json()) def nip89_fetch_events_pubkey(client, pubkey, kind): - ktags = [str(kind)] - # for i in range(5000, 5999): - # ktags.append(str(i)) - nip89filter = (Filter().kind(EventDefinitions.KIND_ANNOUNCEMENT).author(PublicKey.from_hex(pubkey)). - custom_tag(Alphabet.K, ktags)) - events = client.get_events_of([nip89filter], timedelta(seconds=2)) + + ktags = [str(kind.as_u64())] + nip89filter = (Filter().kind(EventDefinitions.KIND_ANNOUNCEMENT).author(PublicKey.parse(pubkey)). + custom_tag(SingleLetterTag.lowercase(Alphabet.K), ktags)) + events = client.get_events_of([nip89filter], timedelta(seconds=4)) dvms = {} for event in events: @@ -98,7 +97,7 @@ def nip89_fetch_events_pubkey(client, pubkey, kind): def check_and_set_d_tag(identifier, name, pk, imageurl): if not os.getenv("NIP89_DTAG_" + identifier.upper()): - new_dtag = nip89_create_d_tag(name, Keys.from_sk_str(pk).public_key().to_hex(), + new_dtag = nip89_create_d_tag(name, Keys.parse(pk).public_key().to_hex(), imageurl) nip89_add_dtag_to_env_file("NIP89_DTAG_" + identifier.upper(), new_dtag) print("Some new dtag:" + new_dtag) diff --git a/nostr_dvm/utils/nostr_utils.py b/nostr_dvm/utils/nostr_utils.py index 0d1a2b4..b3304b4 100644 --- a/nostr_dvm/utils/nostr_utils.py +++ b/nostr_dvm/utils/nostr_utils.py @@ -6,14 +6,14 @@ from typing import List import dotenv from nostr_sdk import Filter, Client, Alphabet, EventId, Event, PublicKey, Tag, Keys, nip04_decrypt, Metadata, Options, \ - Nip19Event + Nip19Event, SingleLetterTag def get_event_by_id(event_id: str, client: Client, config=None) -> Event | None: split = event_id.split(":") if len(split) == 3: pk = PublicKey.from_hex(split[1]) - id_filter = Filter().author(pk).custom_tag(Alphabet.D, [split[2]]) + id_filter = Filter().author(pk).custom_tag(SingleLetterTag.lowercase(Alphabet.D), [split[2]]) events = client.get_events_of([id_filter], timedelta(seconds=config.RELAY_TIMEOUT)) else: if str(event_id).startswith('note'): @@ -37,6 +37,37 @@ def get_event_by_id(event_id: str, client: Client, config=None) -> Event | None: return None +def get_events_by_ids(event_ids, client: Client, config=None) -> List | None: + search_ids = [] + for event_id in event_ids: + split = event_id.split(":") + if len(split) == 3: + pk = PublicKey.from_hex(split[1]) + id_filter = Filter().author(pk).custom_tag(SingleLetterTag.lowercase(Alphabet.D), [split[2]]) + events = client.get_events_of([id_filter], timedelta(seconds=config.RELAY_TIMEOUT)) + else: + if str(event_id).startswith('note'): + event_id = EventId.from_bech32(event_id) + elif str(event_id).startswith("nevent"): + event_id = Nip19Event.from_bech32(event_id).event_id() + elif str(event_id).startswith('nostr:note'): + event_id = EventId.from_nostr_uri(event_id) + elif str(event_id).startswith("nostr:nevent"): + event_id = Nip19Event.from_nostr_uri(event_id).event_id() + + else: + event_id = EventId.from_hex(event_id) + search_ids.append(event_id) + + id_filter = Filter().ids(search_ids) + events = client.get_events_of([id_filter], timedelta(seconds=config.RELAY_TIMEOUT)) + if len(events) > 0: + + return events + else: + return None + + def get_events_by_id(event_ids: list, client: Client, config=None) -> list[Event] | None: id_filter = Filter().ids(event_ids) events = client.get_events_of([id_filter], timedelta(seconds=config.RELAY_TIMEOUT)) @@ -116,7 +147,7 @@ def check_and_decrypt_tags(event, dvm_config): return None elif p == dvm_config.PUBLIC_KEY: - tags_str = nip04_decrypt(Keys.from_sk_str(dvm_config.PRIVATE_KEY).secret_key(), + tags_str = nip04_decrypt(Keys.parse(dvm_config.PRIVATE_KEY).secret_key(), event.author(), event.content()) params = json.loads(tags_str) params.append(Tag.parse(["p", p]).as_vec()) @@ -148,7 +179,7 @@ def check_and_decrypt_own_tags(event, dvm_config): return None elif event.author().to_hex() == dvm_config.PUBLIC_KEY: - tags_str = nip04_decrypt(Keys.from_sk_str(dvm_config.PRIVATE_KEY).secret_key(), + tags_str = nip04_decrypt(Keys.parse(dvm_config.PRIVATE_KEY).secret_key(), PublicKey.from_hex(p), event.content()) params = json.loads(tags_str) params.append(Tag.parse(["p", p]).as_vec()) @@ -164,7 +195,7 @@ def check_and_decrypt_own_tags(event, dvm_config): def update_profile(dvm_config, client, lud16=""): - keys = Keys.from_sk_str(dvm_config.PRIVATE_KEY) + keys = Keys.parse(dvm_config.PRIVATE_KEY) nip89content = json.loads(dvm_config.NIP89.CONTENT) if nip89content.get("name"): name = nip89content.get("name") diff --git a/nostr_dvm/utils/nwc_tools.py b/nostr_dvm/utils/nwc_tools.py index 9938d62..d4e869a 100644 --- a/nostr_dvm/utils/nwc_tools.py +++ b/nostr_dvm/utils/nwc_tools.py @@ -1,44 +1,84 @@ import json import os +from datetime import timedelta import requests -from nostr_sdk import Keys, PublicKey, Client, nip04_encrypt, EventBuilder, Tag, ClientSigner +from nostr_sdk import Keys, PublicKey, Client, nip04_encrypt, EventBuilder, Tag, NostrSigner, Filter, Timestamp, \ + NostrWalletConnectUri, Nwc from nostr_dvm.utils.dvmconfig import DVMConfig from nostr_dvm.utils.nostr_utils import check_and_set_private_key from nostr_dvm.utils.zap_utils import zaprequest -def nwc_zap(connectionstr, bolt11, keys): - target_pubkey, relay, secret = parse_connection_str(connectionstr) - SecretSK = Keys.from_sk_str(secret) +def nwc_zap(connectionstr, bolt11, keys, externalrelay=None): + uri = NostrWalletConnectUri.parse(connectionstr) - content = { - "method": "pay_invoice", - "params": { - "invoice": bolt11 - } - } + # Initialize NWC client + nwc = Nwc(uri) - signer = ClientSigner.keys(keys) - client = Client(signer) - client.add_relay(relay) - client.connect() + info = nwc.get_info() + print(info) - client_public_key = PublicKey.from_hex(target_pubkey) - encrypted_content = nip04_encrypt(SecretSK.secret_key(), client_public_key, json.dumps(content)) + balance = nwc.get_balance() + print(f"Balance: {balance} SAT") - pTag = Tag.parse(["p", client_public_key.to_hex()]) - event = EventBuilder(23194, encrypted_content, - [pTag]).to_event(keys) + event_id = nwc.pay_invoice(bolt11) + print("NWC event: " + event_id) - event_id = client.send_event(event) - print(event_id.to_hex()) + + #target_pubkey, relay, secret = parse_connection_str(connectionstr) + #print(target_pubkey) + #print(relay) + #print(secret) + #SecretSK = Keys.parse(secret) + + #content = { + # "method": "pay_invoice", + # "params": { + # "invoice": bolt11 + # } + #} + + #signer = NostrSigner.keys(keys) + #client = Client(signer) + #client.add_relay(relay) + #if externalrelay is not None: + # client.add_relay(externalrelay) + + #client.connect() + + #client_public_key = PublicKey.from_hex(target_pubkey) + #encrypted_content = nip04_encrypt(SecretSK.secret_key(), client_public_key, json.dumps(content)) + + #pTag = Tag.parse(["p", client_public_key.to_hex()]) + + #event = EventBuilder(23194, encrypted_content, + # [pTag]).to_event(keys) + + #ts = Timestamp.now() + #event_id = client.send_event(event) + + + + + + + #nwc_response_filter = Filter().kind(23195).since(ts) + #events = client.get_events_of([nwc_response_filter], timedelta(seconds=5)) + + #if len(events) > 0: + # for evt in events: + # print(evt.as_json()) + #else: + # print("No response found") + + return event_id def parse_connection_str(connectionstring): split = connectionstring.split("?") - targetpubkey = split[0].split(":")[1] + targetpubkey = split[0].split(":")[1].replace("//", "") split2 = split[1].split("&") relay = split2[0].split("=")[1] relay = relay.replace("%3A%2F%2F", "://") @@ -47,7 +87,7 @@ def parse_connection_str(connectionstring): def make_nwc_account(identifier, nwcdomain): - pubkey = Keys.from_sk_str(os.getenv("DVM_PRIVATE_KEY_" + identifier.upper())).public_key().to_hex() + pubkey = Keys.parse(os.getenv("DVM_PRIVATE_KEY_" + identifier.upper())).public_key().to_hex() data = { 'name': identifier, 'host': os.getenv("LNBITS_HOST"), @@ -77,7 +117,7 @@ def nwc_test(nwc_server): # connectionstring = "nostr+walletconnect:..." if connectionstring != "": # we use the keys from a test user - keys = Keys.from_sk_str(check_and_set_private_key("test")) + keys = Keys.parse(check_and_set_private_key("test")) # we zap npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8's profile 21 sats and say Cool stuff pubkey = PublicKey.from_bech32("npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8") diff --git a/nostr_dvm/utils/output_utils.py b/nostr_dvm/utils/output_utils.py index c5e5347..69a4df4 100644 --- a/nostr_dvm/utils/output_utils.py +++ b/nostr_dvm/utils/output_utils.py @@ -182,36 +182,51 @@ def upload_media_to_hoster(filepath: str): raise Exception("Upload not possible, all hosters didn't work or couldn't generate output") -def build_status_reaction(status, task, amount, content): +def build_status_reaction(status, task, amount, content, dvm_config): alt_description = "This is a reaction to a NIP90 DVM AI task. " if status == "processing": - alt_description = "NIP90 DVM AI task " + task + " started processing. " - reaction = alt_description + emoji.emojize(":thumbs_up:") + if content is not None and content != "": + alt_description = content + reaction = alt_description + else: + alt_description = "NIP90 DVM task " + task + " started processing. " + reaction = alt_description + emoji.emojize(":thumbs_up:") elif status == "success": - alt_description = "NIP90 DVM AI task " + task + " finished successfully. " + alt_description = "NIP90 DVM task " + task + " finished successfully. " reaction = alt_description + emoji.emojize(":call_me_hand:") elif status == "chain-scheduled": - alt_description = "NIP90 DVM AI task " + task + " Chain Task scheduled" + alt_description = "NIP90 DVM task " + task + " Chain Task scheduled" reaction = alt_description + emoji.emojize(":thumbs_up:") elif status == "error": - alt_description = "NIP90 DVM AI task " + task + " had an error. " + alt_description = "NIP90 DVM task " + task + " had an error. " if content is None: reaction = alt_description + emoji.emojize(":thumbs_down:") else: reaction = alt_description + emoji.emojize(":thumbs_down:") + " " + content elif status == "payment-required": - alt_description = "NIP90 DVM AI task " + task + " requires payment of min " + str( + alt_description = "NIP90 DVM task " + task + " requires payment of min " + str( amount) + " Sats. " reaction = alt_description + emoji.emojize(":orange_heart:") + elif status == "subscription-required": + if content is not None and content != "": + alt_description = content + reaction = alt_description + + else: + alt_description = "NIP90 DVM task " + task + " requires payment for subscription" + reaction = alt_description + emoji.emojize(":orange_heart:") + + + elif status == "payment-rejected": - alt_description = "NIP90 DVM AI task " + task + " payment is below required amount of " + str( + alt_description = "NIP90 DVM task " + task + " payment is below required amount of " + str( amount) + " Sats. " reaction = alt_description + emoji.emojize(":thumbs_down:") elif status == "user-blocked-from-service": - alt_description = "NIP90 DVM AI task " + task + " can't be performed. User has been blocked from Service. " + alt_description = "NIP90 DVM task " + task + " can't be performed. User has been blocked from Service. " reaction = alt_description + emoji.emojize(":thumbs_down:") else: reaction = emoji.emojize(":thumbs_down:") diff --git a/nostr_dvm/utils/scrapper/media_scrapper.py b/nostr_dvm/utils/scrapper/media_scrapper.py index 16ba2e1..9b7cb03 100644 --- a/nostr_dvm/utils/scrapper/media_scrapper.py +++ b/nostr_dvm/utils/scrapper/media_scrapper.py @@ -165,7 +165,7 @@ def XitterDownload(source_url, target_location): return details def get_tweet_status_id(tweet_url): - sid_patern = r"https://x\.com/[^/]+/status/(\d+)" + sid_patern = r'https://(?:x\.com|twitter\.com)/[^/]+/status/(\d+)' if tweet_url[len(tweet_url) - 1] != "/": tweet_url = tweet_url + "/" diff --git a/nostr_dvm/utils/subscription_utils.py b/nostr_dvm/utils/subscription_utils.py new file mode 100644 index 0000000..0a6ebb6 --- /dev/null +++ b/nostr_dvm/utils/subscription_utils.py @@ -0,0 +1,173 @@ +import sqlite3 +from dataclasses import dataclass +from sqlite3 import Error + + +@dataclass +class Subscription: + id: str + recipent: str + subscriber: str + nwc: str + cadence: str + amount: int + unit: str + begin: int + end: int + tier_dtag: str + zaps: str + recipe: str + active: bool + lastupdate: int + tier: str + + +def create_subscription_sql_table(db): + try: + import os + if not os.path.exists(r'db'): + os.makedirs(r'db') + if not os.path.exists(r'outputs'): + os.makedirs(r'outputs') + con = sqlite3.connect(db) + cur = con.cursor() + cur.execute(""" CREATE TABLE IF NOT EXISTS subscriptions ( + id text PRIMARY KEY, + recipient text, + subscriber text, + nwc text NOT NULL, + cadence text, + amount int, + unit text, + begin int, + end int, + tier_dtag text, + zaps text, + recipe text, + active boolean, + lastupdate int, + tier text + + + ); """) + cur.execute("SELECT name FROM sqlite_master") + con.close() + + except Error as e: + print(e) + + +def add_to_subscription_sql_table(db, id, recipient, subscriber, nwc, cadence, amount, unit, begin, end, tier_dtag, zaps, + recipe, active, lastupdate, tier): + try: + con = sqlite3.connect(db) + cur = con.cursor() + data = (id, recipient, subscriber, nwc, cadence, amount, unit, begin, end, tier_dtag, zaps, recipe, active, lastupdate, tier) + print(id) + print(recipient) + print(subscriber) + print(nwc) + cur.execute("INSERT or IGNORE INTO subscriptions VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", data) + con.commit() + con.close() + except Error as e: + print("Error when Adding to DB: " + str(e)) + + +def get_from_subscription_sql_table(db, id): + try: + con = sqlite3.connect(db) + cur = con.cursor() + cur.execute("SELECT * FROM subscriptions WHERE id=?", (id,)) + row = cur.fetchone() + con.close() + if row is None: + return None + else: + subscription = Subscription + subscription.id = row[0] + subscription.recipent = row[1] + subscription.subscriber = row[2] + subscription.nwc = row[3] + subscription.cadence = row[4] + subscription.amount = row[5] + subscription.unit = row[6] + subscription.begin = row[7] + subscription.end = row[8] + subscription.tier_dtag = row[9] + subscription.zaps = row[10] + subscription.recipe = row[11] + subscription.active = row[12] + subscription.lastupdate = row[13] + subscription.tier = row[14] + + return subscription + + except Error as e: + print("Error Getting from DB: " + str(e)) + return None + + +def get_all_subscriptions_from_sql_table(db): + try: + con = sqlite3.connect(db) + cursor = con.cursor() + + sqlite_select_query = """SELECT * from subscriptions""" + cursor.execute(sqlite_select_query) + records = cursor.fetchall() + subscriptions = [] + for row in records: + subscriptions.append(Subscription(row[0], row[1], row[2], row[3], row[4], row[5], row[6], row[7], row[8], row[9], row[10], row[11], row[12], row[13], row[14])) + cursor.close() + return subscriptions + + except sqlite3.Error as error: + print("Failed to read data from sqlite table", error) + finally: + if con: + con.close() + #print("The SQLite connection is closed") + +def delete_from_subscription_sql_table(db, id): + try: + con = sqlite3.connect(db) + cur = con.cursor() + cur.execute("DELETE FROM subscriptions WHERE id=?", (id,)) + con.commit() + con.close() + except Error as e: + print(e) + +def update_subscription_sql_table(db, id, recipient, subscriber, nwc, cadence, amount, unit, begin, end, tier_dtag, zaps, + recipe, active, lastupdate, tier): + try: + con = sqlite3.connect(db) + cur = con.cursor() + data = (recipient, subscriber, nwc, cadence, amount, unit, begin, end, tier_dtag, zaps, recipe, active, lastupdate, tier, id) + + cur.execute(""" UPDATE subscriptions + SET recipient = ? , + subscriber = ? , + nwc = ? , + cadence = ? , + amount = ? , + unit = ? , + begin = ? , + end = ?, + tier_dtag = ?, + zaps = ?, + recipe = ?, + active = ?, + lastupdate = ?, + tier = ? + + WHERE id = ?""", data) + con.commit() + con.close() + except Error as e: + print("Error Updating DB: " + str(e)) + + + + diff --git a/nostr_dvm/utils/zap_utils.py b/nostr_dvm/utils/zap_utils.py index c369552..7e1cd4e 100644 --- a/nostr_dvm/utils/zap_utils.py +++ b/nostr_dvm/utils/zap_utils.py @@ -8,7 +8,8 @@ import requests from Crypto.Cipher import AES from Crypto.Util.Padding import pad from bech32 import bech32_decode, convertbits, bech32_encode -from nostr_sdk import nostr_sdk, PublicKey, SecretKey, Event, EventBuilder, Tag, Keys, generate_shared_key +from nostr_sdk import nostr_sdk, PublicKey, SecretKey, Event, EventBuilder, Tag, Keys, generate_shared_key, Kind, \ + Timestamp from nostr_dvm.utils.nostr_utils import get_event_by_id, check_and_decrypt_own_tags import lnurl @@ -50,7 +51,7 @@ def parse_zap_event_tags(zap_event, keys, name, client, config): keys.secret_key(), zap_request_event.author()) decrypted_private_event = Event.from_json(decrypted_content) - if decrypted_private_event.kind() == 9733: + if decrypted_private_event.kind().as_u64() == 9733: sender = decrypted_private_event.author().to_hex() message = decrypted_private_event.content() # if message != "": @@ -132,7 +133,7 @@ def create_bolt11_lud16(lud16, amount): def create_lnbits_account(name): if os.getenv("LNBITS_ADMIN_ID") is None or os.getenv("LNBITS_ADMIN_ID") == "": print("No admin id set, no wallet created.") - return "","","","", "failed" + return "", "", "", "", "failed" data = { 'admin_id': os.getenv("LNBITS_ADMIN_ID"), 'wallet_name': name, @@ -240,6 +241,10 @@ def decrypt_private_zap_message(msg: str, privkey: SecretKey, pubkey: PublicKey) def zaprequest(lud16: str, amount: int, content, zapped_event, zapped_user, keys, relay_list, zaptype="public"): + print(lud16) + print(str(amount)) + print(content) + print(zapped_user.to_hex()) if lud16.startswith("LNURL") or lud16.startswith("lnurl"): url = lnurl.decode(lud16) elif '@' in lud16: # LNaddress @@ -250,6 +255,7 @@ def zaprequest(lud16: str, amount: int, content, zapped_event, zapped_user, keys response = requests.get(url) ob = json.loads(response.content) callback = ob["callback"] + print(ob["callback"]) encoded_lnurl = lnurl.encode(url) amount_tag = Tag.parse(['amount', str(amount * 1000)]) relays_tag = Tag.parse(['relays', str(relay_list)]) @@ -262,20 +268,31 @@ def zaprequest(lud16: str, amount: int, content, zapped_event, zapped_user, keys p_tag = Tag.parse(['p', zapped_user.to_hex()]) tags = [amount_tag, relays_tag, p_tag, lnurl_tag] - if zaptype == "private": - key_str = keys.secret_key().to_hex() + zapped_event.id().to_hex() + str(zapped_event.created_at().as_secs()) - encryption_key = sha256(key_str.encode('utf-8')).hexdigest() + if zapped_event is not None: + key_str = keys.secret_key().to_hex() + zapped_event.id().to_hex() + str( + zapped_event.created_at().as_secs()) + + else: + key_str = keys.secret_key().to_hex() + str(Timestamp.now().as_secs()) + + encryption_key = sha256(key_str.encode('utf-8')).hexdigest() + tags = [p_tag] + if zapped_event is not None: + tags.append(e_tag) + zap_request = EventBuilder(Kind(9733), content, + tags).to_event(keys).as_json() + keys = Keys.parse(encryption_key) + if zapped_event is not None: + encrypted_content = enrypt_private_zap_message(zap_request, keys.secret_key(), zapped_event.author()) + else: + encrypted_content = enrypt_private_zap_message(zap_request, keys.secret_key(), zapped_user) - zap_request = EventBuilder(9733, content, - [p_tag, e_tag]).to_event(keys).as_json() - keys = Keys.from_sk_str(encryption_key) - encrypted_content = enrypt_private_zap_message(zap_request, keys.secret_key(), zapped_event.author()) anon_tag = Tag.parse(['anon', encrypted_content]) tags.append(anon_tag) content = "" - zap_request = EventBuilder(9734, content, + zap_request = EventBuilder(Kind(9734), content, tags).to_event(keys).as_json() response = requests.get(callback + "?amount=" + str(int(amount) * 1000) + "&nostr=" + urllib.parse.quote_plus( @@ -284,22 +301,27 @@ def zaprequest(lud16: str, amount: int, content, zapped_event, zapped_user, keys return ob["pr"] except Exception as e: - print("ZAP REQUEST: " + e) + print("ZAP REQUEST: " + str(e)) return None + def get_price_per_sat(currency): import requests - url = "https://api.coinstats.app/public/v1/coins" + url = "https://openapiv1.coinstats.app/coins/bitcoin" params = {"skip": 0, "limit": 1, "currency": currency} - try: - response = requests.get(url, params=params) - response_json = response.json() + price_currency_per_sat = 0.0004 + if os.getenv("COINSTATSOPENAPI_KEY"): - bitcoin_price = response_json["coins"][0]["price"] - price_currency_per_sat = bitcoin_price / 100000000.0 - except: - price_currency_per_sat = 0.0004 + header = {'accept': 'application/json', 'X-API-KEY': os.getenv("COINSTATSOPENAPI_KEY")} + try: + response = requests.get(url, headers=header, params=params) + response_json = response.json() + + bitcoin_price = response_json["price"] + price_currency_per_sat = bitcoin_price / 100000000.0 + except: + price_currency_per_sat = 0.0004 return price_currency_per_sat @@ -330,8 +352,6 @@ def make_ln_address_nostdress(identifier, npub, pin, nostdressdomain): return "", "" - - def check_and_set_ln_bits_keys(identifier, npub): if not os.getenv("LNBITS_INVOICE_KEY_" + identifier.upper()): invoicekey, adminkey, walletid, userid, success = create_lnbits_account(identifier) @@ -361,4 +381,4 @@ def add_key_to_env_file(value, oskey): env_path = Path('.env') if env_path.is_file(): dotenv.load_dotenv(env_path, verbose=True, override=True) - dotenv.set_key(env_path, value, oskey) \ No newline at end of file + dotenv.set_key(env_path, value, oskey) diff --git a/setup.py b/setup.py index 45d3363..9f6107e 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import setup, find_packages -VERSION = '0.2.1' +VERSION = '0.3.3' DESCRIPTION = 'A framework to build and run Nostr NIP90 Data Vending Machines' LONG_DESCRIPTION = ('A framework to build and run Nostr NIP90 Data Vending Machines. ' 'This is an early stage release. Interfaces might change/brick') @@ -15,7 +15,7 @@ setup( long_description=LONG_DESCRIPTION, packages=find_packages(include=['nostr_dvm', 'nostr_dvm.*']), - install_requires=["nostr-sdk==0.8.0", + install_requires=["nostr-sdk==0.11.0", "bech32", "pycryptodome==3.20.0", "python-dotenv==1.0.0", diff --git a/tests/bot.py b/tests/bot.py new file mode 100644 index 0000000..d2de03e --- /dev/null +++ b/tests/bot.py @@ -0,0 +1,54 @@ +import json +import os +import threading +from pathlib import Path + +import dotenv +from nostr_sdk import Keys + +from nostr_dvm.bot import Bot +from nostr_dvm.tasks import textextraction_pdf +from nostr_dvm.utils.admin_utils import AdminConfig +from nostr_dvm.utils.backend_utils import keep_alive +from nostr_dvm.utils.dvmconfig import DVMConfig +from nostr_dvm.utils.nip89_utils import NIP89Config +from nostr_dvm.utils.nostr_utils import check_and_set_private_key +from nostr_dvm.utils.zap_utils import check_and_set_ln_bits_keys + + +def playground(): + bot_config = DVMConfig() + identifier = "bot_test" + bot_config.PRIVATE_KEY = check_and_set_private_key(identifier) + npub = Keys.parse(bot_config.PRIVATE_KEY).public_key().to_bech32() + invoice_key, admin_key, wallet_id, user_id, lnaddress = check_and_set_ln_bits_keys(identifier, npub) + bot_config.LNBITS_INVOICE_KEY = invoice_key + bot_config.LNBITS_ADMIN_KEY = admin_key # The dvm might pay failed jobs back + bot_config.LNBITS_URL = os.getenv("LNBITS_HOST") + + admin_config = AdminConfig() + + pdfextractor = textextraction_pdf.build_example("PDF Extractor", "pdf_extractor", admin_config) + # If we don't add it to the bot, the bot will not provide access to the DVM + pdfextractor.run() + bot_config.SUPPORTED_DVMS.append(pdfextractor) # We add translator to the bot + + x = threading.Thread(target=Bot, args=([bot_config])) + x.start() + + # Keep the main function alive for libraries that require it, like openai + # keep_alive() + + +if __name__ == '__main__': + env_path = Path('.env') + if not env_path.is_file(): + with open('.env', 'w') as f: + print("Writing new .env file") + f.write('') + if env_path.is_file(): + print(f'loading environment from {env_path.resolve()}') + dotenv.load_dotenv(env_path, verbose=True, override=True) + else: + raise FileNotFoundError(f'.env file not found at {env_path} ') + playground() diff --git a/tests/discovery.py b/tests/discovery.py new file mode 100644 index 0000000..17a2358 --- /dev/null +++ b/tests/discovery.py @@ -0,0 +1,63 @@ +import os +import threading +from pathlib import Path + +import dotenv +from nostr_sdk import Keys + +from nostr_dvm.subscription import Subscription +from nostr_dvm.tasks import content_discovery_currently_popular +from nostr_dvm.utils.admin_utils import AdminConfig +from nostr_dvm.utils.backend_utils import keep_alive +from nostr_dvm.utils.dvmconfig import DVMConfig +from nostr_dvm.utils.nostr_utils import check_and_set_private_key +from nostr_dvm.utils.zap_utils import check_and_set_ln_bits_keys + + +def playground(): + # Generate an optional Admin Config, in this case, whenever we give our DVMs this config, they will (re)broadcast + # their NIP89 announcement + # You can create individual admins configs and hand them over when initializing the dvm, + # for example to whilelist users or add to their balance. + # If you use this global config, options will be set for all dvms that use it. + admin_config = AdminConfig() + admin_config.REBROADCAST_NIP89 = False + admin_config.UPDATE_PROFILE = False + #admin_config.DELETE_NIP89 = True + #admin_config.PRIVKEY = "" + #admin_config.EVENTID = "" + + discovery_test_sub = content_discovery_currently_popular.build_example_subscription("Currently Popular Notes DVM (with Subscriptions)", "discovery_content_test", admin_config) + discovery_test_sub.run() + + #discovery_test = content_discovery_currently_popular.build_example("Currently Popular Notes DVM", + # "discovery_content_test", admin_config) + #discovery_test.run() + + subscription_config = DVMConfig() + subscription_config.PRIVATE_KEY = check_and_set_private_key("dvm_subscription") + npub = Keys.parse(subscription_config.PRIVATE_KEY).public_key().to_bech32() + invoice_key, admin_key, wallet_id, user_id, lnaddress = check_and_set_ln_bits_keys("dvm_subscription", npub) + subscription_config.LNBITS_INVOICE_KEY = invoice_key + subscription_config.LNBITS_ADMIN_KEY = admin_key # The dvm might pay failed jobs back + subscription_config.LNBITS_URL = os.getenv("LNBITS_HOST") + sub_admin_config = AdminConfig() + #sub_admin_config.USERNPUBS = ["7782f93c5762538e1f7ccc5af83cd8018a528b9cd965048386ca1b75335f24c6"] #Add npubs of services that can contact the subscription handler + x = threading.Thread(target=Subscription, args=(Subscription(subscription_config, sub_admin_config),)) + x.start() + + #keep_alive() + + +if __name__ == '__main__': + env_path = Path('.env') + if not env_path.is_file(): + with open('.env', 'w') as f: + print("Writing new .env file") + f.write('') + if env_path.is_file(): + print(f'loading environment from {env_path.resolve()}') + dotenv.load_dotenv(env_path, verbose=True, override=True) + else: + raise FileNotFoundError(f'.env file not found at {env_path} ') + playground() diff --git a/tests/filter.py b/tests/filter.py new file mode 100644 index 0000000..d69d664 --- /dev/null +++ b/tests/filter.py @@ -0,0 +1,52 @@ +import os +import threading +from pathlib import Path + +import dotenv +from nostr_sdk import Keys + +from nostr_dvm.subscription import Subscription +from nostr_dvm.tasks import content_discovery_currently_popular, discovery_censor_wot, discovery_inactive_follows, \ + discovery_bot_farms +from nostr_dvm.utils.admin_utils import AdminConfig +from nostr_dvm.utils.backend_utils import keep_alive +from nostr_dvm.utils.dvmconfig import DVMConfig +from nostr_dvm.utils.nostr_utils import check_and_set_private_key +from nostr_dvm.utils.zap_utils import check_and_set_ln_bits_keys + + +def playground(): + # Generate an optional Admin Config, in this case, whenever we give our DVMs this config, they will (re)broadcast + # their NIP89 announcement + # You can create individual admins configs and hand them over when initializing the dvm, + # for example to whilelist users or add to their balance. + # If you use this global config, options will be set for all dvms that use it. + admin_config = AdminConfig() + admin_config.REBROADCAST_NIP89 = False + admin_config.UPDATE_PROFILE = False + + #discovery_test_sub = discovery_censor_wot.build_example("Censorship", "discovery_censor", admin_config) + #discovery_test_sub.run() + + discovery_test_sub = discovery_bot_farms.build_example("Bot Hunter", "discovery_botfarms", admin_config) + discovery_test_sub.run() + #discovery_test_sub = discovery_inactive_follows.build_example("Inactive Followings", "discovery_inactive", admin_config) + #discovery_test_sub.run() + + + + #keep_alive() + + +if __name__ == '__main__': + env_path = Path('.env') + if not env_path.is_file(): + with open('.env', 'w') as f: + print("Writing new .env file") + f.write('') + if env_path.is_file(): + print(f'loading environment from {env_path.resolve()}') + dotenv.load_dotenv(env_path, verbose=True, override=True) + else: + raise FileNotFoundError(f'.env file not found at {env_path} ') + playground() diff --git a/tests/gui/nicegui/nostrAI_search_client.py b/tests/gui/nicegui/nostrAI_search_client.py index 9d12805..4ab3b29 100644 --- a/tests/gui/nicegui/nostrAI_search_client.py +++ b/tests/gui/nicegui/nostrAI_search_client.py @@ -3,7 +3,7 @@ import time from datetime import timedelta from nicegui import run, ui from nostr_sdk import Keys, Client, Tag, EventBuilder, Filter, \ - Options, Timestamp, ClientSigner, EventId, Nip19Event, PublicKey + Options, Timestamp, NostrSigner, EventId, Nip19Event, PublicKey from nostr_dvm.utils import dvmconfig from nostr_dvm.utils.dvmconfig import DVMConfig @@ -13,11 +13,11 @@ from nostr_dvm.utils.definitions import EventDefinitions @ui.page('/', dark=True) def init(): - keys = Keys.from_sk_str(check_and_set_private_key("test_client")) + keys = Keys.parse(check_and_set_private_key("test_client")) opts = (Options().wait_for_send(False).send_timeout(timedelta(seconds=2)) .skip_disconnected_relays(True)) - signer = ClientSigner.keys(keys) + signer = NostrSigner.keys(keys) client = Client.with_opts(signer, opts) relay_list = dvmconfig.DVMConfig.RELAY_LIST diff --git a/tests/nwc.py b/tests/nwc.py new file mode 100644 index 0000000..3a8c57b --- /dev/null +++ b/tests/nwc.py @@ -0,0 +1,33 @@ +import os +from pathlib import Path + +import dotenv +from nostr_sdk import Keys, PublicKey + +from nostr_dvm.utils import dvmconfig +from nostr_dvm.utils.nwc_tools import nwc_zap +from nostr_dvm.utils.zap_utils import create_bolt11_lud16, zaprequest + + +def playground(): + + connectionstr = os.getenv("TEST_NWC") + keys = Keys.parse(os.getenv("TEST_USER")) + bolt11 = zaprequest("bot@nostrdvm.com", 5, "test", None, PublicKey.parse("npub1cc79kn3phxc7c6mn45zynf4gtz0khkz59j4anew7dtj8fv50aqrqlth2hf"), keys, dvmconfig.DVMConfig.RELAY_LIST, zaptype="private") + print(bolt11) + result = nwc_zap(connectionstr, bolt11, keys, externalrelay=None) + print(result) + + +if __name__ == '__main__': + env_path = Path('.env') + if not env_path.is_file(): + with open('.env', 'w') as f: + print("Writing new .env file") + f.write('') + if env_path.is_file(): + print(f'loading environment from {env_path.resolve()}') + dotenv.load_dotenv(env_path, verbose=True, override=True) + else: + raise FileNotFoundError(f'.env file not found at {env_path} ') + playground() \ No newline at end of file diff --git a/tests/test_dvm_client.py b/tests/test_dvm_client.py index 7f13627..67652cb 100644 --- a/tests/test_dvm_client.py +++ b/tests/test_dvm_client.py @@ -5,7 +5,7 @@ from threading import Thread import dotenv from nostr_sdk import Keys, Client, Tag, EventBuilder, Filter, HandleNotification, Timestamp, nip04_decrypt, \ - nip04_encrypt, ClientSigner + nip04_encrypt, NostrSigner, PublicKey, Event, Kind, RelayOptions from nostr_dvm.utils.dvmconfig import DVMConfig from nostr_dvm.utils.nostr_utils import send_event, check_and_set_private_key @@ -14,7 +14,7 @@ from nostr_dvm.utils.definitions import EventDefinitions # TODO HINT: Best use this path with a previously whitelisted privkey, as zapping events is not implemented in the lib/code def nostr_client_test_translation(input, kind, lang, sats, satsmax): - keys = Keys.from_sk_str(check_and_set_private_key("test_client")) + keys = Keys.parse(check_and_set_private_key("test_client")) if kind == "text": iTag = Tag.parse(["i", input, "text"]) elif kind == "event": @@ -31,7 +31,32 @@ def nostr_client_test_translation(input, kind, lang, sats, satsmax): relay_list = ["wss://relay.damus.io", "wss://blastr.f7z.xyz", "wss://relayable.org", "wss://nostr-pub.wellorder.net"] - signer = ClientSigner.keys(keys) + signer = NostrSigner.keys(keys) + client = Client(signer) + + for relay in relay_list: + client.add_relay(relay) + client.connect() + config = DVMConfig + send_event(event, client=client, dvm_config=config) + return event.as_json() + + +def nostr_client_test_search_profile(input): + keys = Keys.parse(check_and_set_private_key("test_client")) + + iTag = Tag.parse(["i", input, "text"]) + + relaysTag = Tag.parse(['relays', "wss://relay.damus.io", "wss://blastr.f7z.xyz", "wss://relayable.org", + "wss://nostr-pub.wellorder.net"]) + alttag = Tag.parse(["alt", "This is a NIP90 DVM AI task to translate a given Input"]) + event = EventBuilder(EventDefinitions.KIND_NIP90_USER_SEARCH, str("Search for user"), + [iTag, relaysTag, alttag]).to_event(keys) + + relay_list = ["wss://relay.damus.io", "wss://blastr.f7z.xyz", "wss://relayable.org", + "wss://nostr-pub.wellorder.net"] + + signer = NostrSigner.keys(keys) client = Client(signer) for relay in relay_list: @@ -43,7 +68,7 @@ def nostr_client_test_translation(input, kind, lang, sats, satsmax): def nostr_client_test_image(prompt): - keys = Keys.from_sk_str(check_and_set_private_key("test_client")) + keys = Keys.parse(check_and_set_private_key("test_client")) iTag = Tag.parse(["i", prompt, "text"]) outTag = Tag.parse(["output", "image/png;format=url"]) @@ -60,7 +85,7 @@ def nostr_client_test_image(prompt): relay_list = ["wss://relay.damus.io", "wss://blastr.f7z.xyz", "wss://relayable.org", "wss://nostr-pub.wellorder.net"] - signer = ClientSigner.keys(keys) + signer = NostrSigner.keys(keys) client = Client(signer) for relay in relay_list: client.add_relay(relay) @@ -70,8 +95,63 @@ def nostr_client_test_image(prompt): return event.as_json() +def nostr_client_test_censor_filter(users): + keys = Keys.parse(check_and_set_private_key("test_client")) + + relay_list = ["wss://relay.damus.io", "wss://blastr.f7z.xyz", "wss://relayable.org", + ] + + relaysTag = Tag.parse(relay_list) + alttag = Tag.parse(["alt", "This is a NIP90 DVM AI task to find people to ignore based on people the user trusts"]) + # pTag = Tag.parse(["p", user, "text"]) + tags = [relaysTag, alttag] + for user in users: + iTag = Tag.parse(["i", user, "text"]) + tags.append(iTag) + + event = EventBuilder(EventDefinitions.KIND_NIP90_PEOPLE_DISCOVERY, str("Give me bad actors"), + tags).to_event(keys) + + signer = NostrSigner.keys(keys) + client = Client(signer) + for relay in relay_list: + client.add_relay(relay) + client.connect() + config = DVMConfig + send_event(event, client=client, dvm_config=config) + return event.as_json() + + +def nostr_client_test_inactive_filter(user): + keys = Keys.parse(check_and_set_private_key("test_client")) + + relay_list = ["wss://relay.damus.io", "wss://blastr.f7z.xyz", + ] + + relaysTag = Tag.parse(relay_list) + alttag = Tag.parse(["alt", "This is a NIP90 DVM AI task to find people that are inactive"]) + paramTag = Tag.parse(["param", "user", user]) + paramTag2 = Tag.parse(["param", "since_days", "120"]) + + tags = [relaysTag, alttag, paramTag, paramTag2] + + + event = EventBuilder(EventDefinitions.KIND_NIP90_PEOPLE_DISCOVERY, str("Give me inactive users"), + tags).to_event(keys) + + signer = NostrSigner.keys(keys) + client = Client(signer) + for relay in relay_list: + client.add_relay(relay) + ropts = RelayOptions().ping(False) + client.add_relay_with_opts("wss://nostr.band", ropts) + client.connect() + config = DVMConfig + send_event(event, client=client, dvm_config=config) + return event.as_json() + def nostr_client_test_tts(prompt): - keys = Keys.from_sk_str(check_and_set_private_key("test_client")) + keys = Keys.parse(check_and_set_private_key("test_client")) iTag = Tag.parse(["i", prompt, "text"]) paramTag1 = Tag.parse(["param", "language", "en"]) @@ -84,9 +164,9 @@ def nostr_client_test_tts(prompt): [iTag, paramTag1, bidTag, relaysTag, alttag]).to_event(keys) relay_list = ["wss://relay.damus.io", "wss://blastr.f7z.xyz", "wss://relayable.org", - "wss://nostr-pub.wellorder.net"] + ] - signer = ClientSigner.keys(keys) + signer = NostrSigner.keys(keys) client = Client(signer) for relay in relay_list: client.add_relay(relay) @@ -97,10 +177,8 @@ def nostr_client_test_tts(prompt): def nostr_client_test_image_private(prompt, cashutoken): - keys = Keys.from_sk_str(check_and_set_private_key("test_client")) - receiver_keys = Keys.from_sk_str(check_and_set_private_key("replicate_sdxl")) - - # TODO more advanced logic, more parsing, params etc, just very basic test functions for now + keys = Keys.parse(check_and_set_private_key("test_client")) + receiver_keys = Keys.parse(check_and_set_private_key("replicate_sdxl")) relay_list = ["wss://relay.damus.io", "wss://blastr.f7z.xyz", "wss://relayable.org", "wss://nostr-pub.wellorder.net"] @@ -125,7 +203,7 @@ def nostr_client_test_image_private(prompt, cashutoken): nip90request = EventBuilder(EventDefinitions.KIND_NIP90_GENERATE_IMAGE, encrypted_params, [pTag, encrypted_tag]).to_event(keys) - signer = ClientSigner.keys(keys) + signer = NostrSigner.keys(keys) client = Client(signer) for relay in relay_list: client.add_relay(relay) @@ -136,11 +214,11 @@ def nostr_client_test_image_private(prompt, cashutoken): def nostr_client(): - keys = Keys.from_sk_str(check_and_set_private_key("test_client")) + keys = Keys.parse(check_and_set_private_key("test_client")) sk = keys.secret_key() pk = keys.public_key() print(f"Nostr Client public key: {pk.to_bech32()}, Hex: {pk.to_hex()} ") - signer = ClientSigner.keys(keys) + signer = NostrSigner.keys(keys) client = Client(signer) dvmconfig = DVMConfig() @@ -151,33 +229,42 @@ def nostr_client(): dm_zap_filter = Filter().pubkey(pk).kinds([EventDefinitions.KIND_DM, EventDefinitions.KIND_ZAP]).since( Timestamp.now()) # events to us specific - dvm_filter = (Filter().kinds([EventDefinitions.KIND_NIP90_RESULT_TRANSLATE_TEXT, - EventDefinitions.KIND_FEEDBACK]).since(Timestamp.now())) # public events - client.subscribe([dm_zap_filter, dvm_filter]) + kinds = [EventDefinitions.KIND_NIP90_GENERIC] + SUPPORTED_KINDS = [Kind(6301)] + + for kind in SUPPORTED_KINDS: + if kind not in kinds: + kinds.append(kind) + dvm_filter = (Filter().kinds(kinds).since(Timestamp.now())) + client.subscribe([dm_zap_filter, dvm_filter], None) # nostr_client_test_translation("This is the result of the DVM in spanish", "text", "es", 20, 20) # nostr_client_test_translation("note1p8cx2dz5ss5gnk7c59zjydcncx6a754c0hsyakjvnw8xwlm5hymsnc23rs", "event", "es", 20,20) # nostr_client_test_translation("44a0a8b395ade39d46b9d20038b3f0c8a11168e67c442e3ece95e4a1703e2beb", "event", "zh", 20, 20) - nostr_client_test_image("a beautiful purple ostrich watching the sunset") + # nostr_client_test_image("a beautiful purple ostrich watching the sunset") + # nostr_client_test_search_profile("dontbelieve") + wot = ["99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64"] + #nostr_client_test_censor_filter(wot) + nostr_client_test_inactive_filter("99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64") # nostr_client_test_tts("Hello, this is a test. Mic check one, two.") # cashutoken = "cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJpZCI6InZxc1VRSVorb0sxOSIsImFtb3VudCI6MSwiQyI6IjAyNWU3ODZhOGFkMmExYTg0N2YxMzNiNGRhM2VhMGIyYWRhZGFkOTRiYzA4M2E2NWJjYjFlOTgwYTE1NGIyMDA2NCIsInNlY3JldCI6InQ1WnphMTZKMGY4UElQZ2FKTEg4V3pPck5rUjhESWhGa291LzVzZFd4S0U9In0seyJpZCI6InZxc1VRSVorb0sxOSIsImFtb3VudCI6NCwiQyI6IjAyOTQxNmZmMTY2MzU5ZWY5ZDc3MDc2MGNjZmY0YzliNTMzMzVmZTA2ZGI5YjBiZDg2Njg5Y2ZiZTIzMjVhYWUwYiIsInNlY3JldCI6IlRPNHB5WE43WlZqaFRQbnBkQ1BldWhncm44UHdUdE5WRUNYWk9MTzZtQXM9In0seyJpZCI6InZxc1VRSVorb0sxOSIsImFtb3VudCI6MTYsIkMiOiIwMmRiZTA3ZjgwYmMzNzE0N2YyMDJkNTZiMGI3ZTIzZTdiNWNkYTBhNmI3Yjg3NDExZWYyOGRiZDg2NjAzNzBlMWIiLCJzZWNyZXQiOiJHYUNIdHhzeG9HM3J2WWNCc0N3V0YxbU1NVXczK0dDN1RKRnVwOHg1cURzPSJ9XSwibWludCI6Imh0dHBzOi8vbG5iaXRzLmJpdGNvaW5maXhlc3RoaXMub3JnL2Nhc2h1L2FwaS92MS9ScDlXZGdKZjlxck51a3M1eVQ2SG5rIn1dfQ==" # nostr_client_test_image_private("a beautiful ostrich watching the sunset") class NotificationHandler(HandleNotification): - def handle(self, relay_url, event): + def handle(self, relay_url, subscription_id, event: Event): print(f"Received new event from {relay_url}: {event.as_json()}") - if event.kind() == 7000: + if event.kind().as_u64() == 7000: print("[Nostr Client]: " + event.as_json()) - elif 6000 < event.kind() < 6999: + elif 6000 < event.kind().as_u64() < 6999: print("[Nostr Client]: " + event.as_json()) print("[Nostr Client]: " + event.content()) - elif event.kind() == 4: + elif event.kind().as_u64() == 4: dec_text = nip04_decrypt(sk, event.author(), event.content()) print("[Nostr Client]: " + f"Received new msg: {dec_text}") - elif event.kind() == 9735: + elif event.kind().as_u64() == 9735: print("[Nostr Client]: " + f"Received new zap:") print(event.as_json()) diff --git a/tests/test_events.py b/tests/test_events.py index 8489f63..fd0a4e4 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -4,20 +4,20 @@ from pathlib import Path import dotenv import nostr_sdk from nostr_sdk import Keys, Client, Tag, EventBuilder, Filter, HandleNotification, Timestamp, nip04_decrypt, \ - nip04_encrypt, EventId, Options, PublicKey, Event, ClientSigner, Nip19Event + nip04_encrypt, EventId, Options, PublicKey, Event, NostrSigner, Nip19Event from nostr_dvm.utils import definitions, dvmconfig from nostr_dvm.utils.nostr_utils import check_and_set_private_key relay_list = dvmconfig.DVMConfig.RELAY_LIST -keys = Keys.from_sk_str(check_and_set_private_key("test_client")) +keys = Keys.parse(check_and_set_private_key("test_client")) wait_for_send = False skip_disconnected_relays = True opts = (Options().wait_for_send(wait_for_send).send_timeout(timedelta(seconds=5)) .skip_disconnected_relays(skip_disconnected_relays)) -signer = ClientSigner.keys(keys) +signer = NostrSigner.keys(keys) client = Client.with_opts(signer, opts) for relay in relay_list: diff --git a/ui/noogle/.env_example b/ui/noogle/.env_example new file mode 100644 index 0000000..9c0f0f7 --- /dev/null +++ b/ui/noogle/.env_example @@ -0,0 +1,2 @@ +VITE_NOOGLE_PK="" +VITE_SUBSCRIPTIPON_VERIFIER_PUBKEY="" \ No newline at end of file diff --git a/ui/noogle/index.html b/ui/noogle/index.html index 08d9647..b64a870 100644 --- a/ui/noogle/index.html +++ b/ui/noogle/index.html @@ -4,7 +4,7 @@ - Nooooooooogle + Nostr decentralized search and other stuff
diff --git a/ui/noogle/package.json b/ui/noogle/package.json index 5be386c..e43a60d 100644 --- a/ui/noogle/package.json +++ b/ui/noogle/package.json @@ -11,13 +11,16 @@ "type-check": "vue-tsc --build --force" }, "dependencies": { - "@rust-nostr/nostr-sdk": "^0.10.0", + "@getalby/sdk": "^3.4.0", + "@rust-nostr/nostr-sdk": "^0.13.1", + "@vuepic/vue-datepicker": "^7.4.1", "@vueuse/core": "^10.7.2", "bech32": "^2.0.0", "bootstrap": "^5.3.2", "daisyui": "^4.6.0", "mini-toastr": "^0.8.1", - "nostr-tools": "^1.17.0", + "nostr-login": "^1.1.1", + "nostr-tools": "^2.4.0", "vue": "^3.4.15", "vue-notifications": "^1.0.2", "vue3-easy-data-table": "^1.5.47", @@ -25,9 +28,10 @@ "webln": "^0.3.2" }, "devDependencies": { + "@tsconfig/node20": "^20.1.2", + "@types/node": "^20.11.10", "@vitejs/plugin-vue": "^4.5.2", "@vue/tsconfig": "^0.5.1", - "@types/node": "^20.11.10", "autoprefixer": "^10.4.17", "postcss": "^8.4.33", "sass": "^1.70.0", @@ -35,8 +39,7 @@ "typescript": "~5.3.0", "vite": "^5.0.10", "vue-router": "^4.2.5", - "vue-tsc": "^1.8.27", - "@tsconfig/node20": "^20.1.2" + "vue-tsc": "^1.8.27" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.6.1" diff --git a/ui/noogle/public/Alby.jpg b/ui/noogle/public/Alby.jpg new file mode 100644 index 0000000..40a2a2d Binary files /dev/null and b/ui/noogle/public/Alby.jpg differ diff --git a/ui/noogle/public/Mutiny.png b/ui/noogle/public/Mutiny.png new file mode 100644 index 0000000..1d45cce Binary files /dev/null and b/ui/noogle/public/Mutiny.png differ diff --git a/ui/noogle/public/NWA.png b/ui/noogle/public/NWA.png new file mode 100644 index 0000000..b35e2d7 Binary files /dev/null and b/ui/noogle/public/NWA.png differ diff --git a/ui/noogle/public/NWC.png b/ui/noogle/public/NWC.png new file mode 100644 index 0000000..b35e2d7 Binary files /dev/null and b/ui/noogle/public/NWC.png differ diff --git a/ui/noogle/public/shipyard.ico b/ui/noogle/public/shipyard.ico new file mode 100644 index 0000000..19ef70a Binary files /dev/null and b/ui/noogle/public/shipyard.ico differ diff --git a/ui/noogle/src/App.vue b/ui/noogle/src/App.vue index ae9d1c4..4ea3646 100644 --- a/ui/noogle/src/App.vue +++ b/ui/noogle/src/App.vue @@ -1,7 +1,9 @@ @@ -11,9 +13,11 @@ import Nip07 from "@/components/Nip07.vue";
-