Building a Zapvertiser Bot with Bitvora
In this tutorial, we'll build a Nostr Zap Bot using Go. This bot will follow profiles, identify those that have a Lightning Address, and send them a "zap" (a small Bitcoin payment) using LNURL. We'll walk through the process in bite-sized chunks, explaining each part of the code and how it works.
Prerequisites
You'll need the following:
- A basic understanding of Go
- Go installed on your machine
- Bitvora Go SDK
- A Bitvora Account and API Key
Now, let’s break it down step by step.
Setting Up the Environment
First, we need to load environment variables such as our secret key for signing Nostr events and the Bitvora API key. For that, we’ll use godotenv
:
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
Make sure you create a .env
file with your Nostr secret key (SK
) and your Bitvora API key (BITVORA_API_KEY
). It should look like this:
SK=your_nostr_secret_key
BITVORA_API_KEY=your_bitvora_api_key
RELAYS=wot.utxo.one,wot.nostr.party,nostrelites.org,wot.nostr.net
Getting Relay URLs
Nostr requires relays to broadcast and receive messages. We can grab these from our .env
file and format them properly:
func getRelays() []string {
relays := os.Getenv("RELAYS")
relayList := strings.Split(relays, ",")
var prefixedRelays []string
for _, relay := range relayList {
prefixedRelays = append(prefixedRelays, "wss://"+relay)
}
return prefixedRelays
}
This function will return an array of WebSocket relay URLs that we’ll use to connect to the Nostr network.
Fetching Follows from a Profile
We need to get a list of profiles that a Nostr user is following. This is done by fetching Nostr events of type KindFollowList
for the user’s public key.
func getFollows(pubkey string) []string {
filters := []nostr.Filter{{
Authors: []string{pubkey},
Kinds: []int{nostr.KindFollowList},
}}
var pubkeys []string
for ev := range pool.SubManyEose(ctx, getRelays(), filters) {
for _, contact := range ev.Event.Tags.GetAll([]string{"p"}) {
if len(contact) < 2 {
continue
}
pubkeys = append(pubkeys, contact[1])
}
}
return pubkeys
}
This function connects to the relays, subscribes to follow events, and parses the returned event tags to extract the public keys of followed profiles.
Extracting Lightning Addresses
Once we have a list of profiles, we need to check if these profiles have a Lightning Address for receiving zaps. We’ll parse the Nostr profile metadata (KindProfileMetadata
) to find the lud16
field, which contains the Lightning Address.
func extractLightningAddress(ev *nostr.Event) string {
var content map[string]interface{}
if err := json.Unmarshal([]byte(ev.Content), &content); err != nil {
log.Fatalf("Error parsing content JSON: %v", err)
}
lud16, ok := content["lud16"].(string)
if !ok {
return ""
}
return lud16
}
If the profile has a Lightning Address, it will be extracted and returned.
Zapping Profiles
Now that we have a list of profiles with Lightning Addresses, we can proceed to zap them! We first create a Nostr Zap Request event:
func zapProfile(pubkey string, lightningAddress string, amountSats int64, message string) {
sk := os.Getenv("SK")
amountMilliSats := amountSats * 1000
relays := nostr.Tag{"relays"}
for _, relay := range getRelays() {
relays = append(relays, relay)
}
zapRequest := nostr.Event{
Kind: nostr.KindZapRequest,
Content: message,
Tags: nostr.Tags{
relays,
nostr.Tag{"amount", fmt.Sprint(amountMilliSats)},
nostr.Tag{"p", pubkey},
},
CreatedAt: nostr.Now(),
}
zapRequest.Sign(sk)
This creates a new zap event, signs it using our secret key, and formats the data properly to include the public key of the profile, the zap amount (in milli-satoshis), and the message.
Getting the LNURL Callback
Next, we need to retrieve the LNURL callback URL, which is used to generate an invoice for the zap.
func getLnurlCallbackUrl(lnAddr string) string {
atIndex := strings.Index(lnAddr, "@")
if atIndex == -1 {
fmt.Println("Invalid Lightning Address format:", lnAddr)
return ""
}
handle := lnAddr[:strings.Index(lnAddr, "@")]
domain := lnAddr[strings.Index(lnAddr, "@")+1:]
url := "https://" + domain + "/.well-known/lnurlp/" + handle
response, err := http.Get(url)
if err != nil {
return "Error: " + err.Error()
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "Error: Non-OK HTTP status: " + response.Status
}
var lnurlResponse LnurlResponse
if err := json.NewDecoder(response.Body).Decode(&lnurlResponse); err != nil {
return "Error: Unable to parse JSON response: " + err.Error()
}
return lnurlResponse.Callback
}
This function takes the Lightning Address (in the format [email protected]
), formats the URL, and sends an HTTP GET request to retrieve the LNURL callback.
Completing the Zap
After getting the LNURL callback, we can send a zap to the Lightning Address by generating an invoice and paying it via the Bitvora API.
param := url.Values{}
param.Set("amount", fmt.Sprint(amountMilliSats))
param.Set("nostr", string(jsonZapRequest))
u, err := url.Parse(callback)
if err != nil {
fmt.Println("Error parsing callback URL:", err)
return
}
u.RawQuery = param.Encode()
resp, err := http.Get(u.String())
if err != nil {
fmt.Println("Error sending callback request:", err)
return
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("Error reading callback response:", err)
return
}
var invoice Invoice
err = json.Unmarshal(bodyBytes, &invoice)
if err != nil {
fmt.Println("Error unmarshalling invoice:", err)
return
}
client := bitvora.NewBitvoraClient(bitvora.Mainnet, apiKey)
_, err = client.Withdraw(float64(amountSats), bitvora.SATS, invoice.PR, nil)
if err != nil {
fmt.Println("Error withdrawing from Bitvora:", err)
return
}
fmt.Println("⚡ zapped", lightningAddress, "with", amountSats, "sats")
}
Once the invoice is retrieved, we pass it to the Bitvora client to make the payment. The bot will then confirm the zap.
Running the Bot
Finally, we can tie it all together by fetching the followed profiles and zapping them:
func main() {
zapProfiles(getFollows("e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb"), 3, "I am utxo's bot. If you follow me, you'll get more zaps 🤖")
}
Replace the public key with the user’s key that the bot will follow, and adjust the zap amount as necessary.
Conclusion
This tutorial walks you through building a simple Nostr Zap Bot using Go. The bot identifies profiles with Lightning Addresses and sends them zaps using LNURL and the Bitvora API. The code is modular and extensible, so feel free to enhance the bot’s functionality as needed!
Complete code
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"github.com/bitvora/go-bitvora"
"github.com/joho/godotenv"
"github.com/nbd-wtf/go-nostr"
)
type LnurlResponse struct {
Status string `json:"status"`
Tag string `json:"tag"`
CommentAllowed int `json:"commentAllowed"`
Callback string `json:"callback"`
Metadata string `json:"metadata"`
MinSendable int `json:"minSendable"`
MaxSendable int `json:"maxSendable"`
NostrPubkey string `json:"nostrPubkey"`
AllowsNostr bool `json:"allowsNostr"`
}
type LnurlCallbackResponse struct {
Status string `json:"status"`
SuccessAction struct {
Tag string `json:"tag"`
Message string `json:"message"`
} `json:"successAction"`
Verify string `json:"verify"`
Routes []string `json:"routes"`
Pr string `json:"pr"`
}
type Invoice struct {
PR string `json:"pr"`
}
var ctx = context.Background()
var pool = nostr.NewSimplePool(ctx)
func main() {
zapProfiles(getFollows("e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb"), 3, "I am utxo's bot. If you follow me, you'll get more zaps 🤖")
}
func zapProfiles(pubkeys []string, amountSats int64, message string) {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
relays := nostr.Tag{"relays"}
for _, relay := range getRelays() {
relays = append(relays, relay)
}
filters := []nostr.Filter{{
Kinds: []int{nostr.KindProfileMetadata},
Authors: pubkeys,
}}
var zappedProfiles = make(map[string]bool)
for ev := range pool.SubManyEose(ctx, getRelays(), filters) {
if !zappedProfiles[ev.Event.PubKey] {
lightningAddress := extractLightningAddress(ev.Event)
if lightningAddress == "" {
continue
}
zapProfile(ev.Event.PubKey, lightningAddress, amountSats, message)
zappedProfiles[ev.Event.PubKey] = true
}
}
}
func zapProfile(pubkey string, lightningAddress string, amountSats int64, message string) {
sk := os.Getenv("SK")
apiKey := os.Getenv("BITVORA_API_KEY")
amountMilliSats := amountSats * 1000
relays := nostr.Tag{"relays"}
for _, relay := range getRelays() {
relays = append(relays, relay)
}
zapRequest := nostr.Event{
Kind: nostr.KindZapRequest,
Content: message,
Tags: nostr.Tags{
relays,
nostr.Tag{"amount", fmt.Sprint(amountMilliSats)},
nostr.Tag{"p", pubkey},
},
CreatedAt: nostr.Now(),
}
zapRequest.Sign(sk)
jsonZapRequest, err := zapRequest.MarshalJSON()
if err != nil {
fmt.Println("Error marshalling zap request:", err)
}
callback := getLnurlCallbackUrl(lightningAddress)
if callback == "" {
fmt.Println("Error getting callback URL for", lightningAddress)
return
}
param := url.Values{}
param.Set("amount", fmt.Sprint(amountMilliSats))
param.Set("nostr", string(jsonZapRequest))
u, err := url.Parse(callback)
if err != nil {
fmt.Println("Error parsing callback URL:", err)
return
}
u.RawQuery = param.Encode()
resp, err := http.Get(u.String())
if err != nil {
fmt.Println("Error sending callback request:", err)
return
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("Error reading callback response:", err)
return
}
var invoice Invoice
err = json.Unmarshal(bodyBytes, &invoice)
if err != nil {
fmt.Println("Error unmarshalling invoice:", err)
return
}
if invoice.PR == "" {
fmt.Println("Error: no payment request in callback response")
return
}
client := bitvora.NewBitvoraClient(bitvora.Mainnet, apiKey)
_, err = client.Withdraw(float64(amountSats), bitvora.SATS, invoice.PR, nil)
if err != nil {
fmt.Println("Error withdrawing from Bitvora:", err)
return
}
fmt.Println("⚡ zapped", lightningAddress, "with", amountSats, "sats")
}
func getLnurlCallbackUrl(lnAddr string) string {
atIndex := strings.Index(lnAddr, "@")
if atIndex == -1 {
// Invalid format, skip processing
fmt.Println("Invalid Lightning Address format:", lnAddr)
return ""
}
handle := lnAddr[:strings.Index(lnAddr, "@")]
domain := lnAddr[strings.Index(lnAddr, "@")+1:]
url := "https://" + domain + "/.well-known/lnurlp/" + handle
response, err := http.Get(url)
if err != nil {
return "Error: " + err.Error()
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "Error: Non-OK HTTP status: " + response.Status
}
var lnurlResponse LnurlResponse
if err := json.NewDecoder(response.Body).Decode(&lnurlResponse); err != nil {
return "Error: Unable to parse JSON response: " + err.Error()
}
return lnurlResponse.Callback
}
func getRelays() []string {
relays := os.Getenv("RELAYS")
relayList := strings.Split(relays, ",")
var prefixedRelays []string
for _, relay := range relayList {
prefixedRelays = append(prefixedRelays, "wss://"+relay)
}
return prefixedRelays
}
func extractLightningAddress(ev *nostr.Event) string {
jsonEvent, err := json.Marshal(ev)
if err != nil {
panic(err)
}
if err := json.Unmarshal([]byte(jsonEvent), &ev); err != nil {
log.Fatalf("Error parsing event JSON: %v", err)
}
var content map[string]interface{}
if err := json.Unmarshal([]byte(ev.Content), &content); err != nil {
log.Fatalf("Error parsing content JSON: %v", err)
}
lud16, ok := content["lud16"].(string)
if !ok {
return ""
}
return lud16
}
func extractName(ev *nostr.Event) string {
jsonEvent, err := json.Marshal(ev)
if err != nil {
panic(err)
}
if err := json.Unmarshal([]byte(jsonEvent), &ev); err != nil {
log.Fatalf("Error parsing event JSON: %v", err)
}
var content map[string]interface{}
if err := json.Unmarshal([]byte(ev.Content), &content); err != nil {
log.Fatalf("Error parsing content JSON: %v", err)
}
name, ok := content["name"].(string)
if !ok {
log.Fatal("name field not found")
}
return name
}
func getFollows(pubkey string) []string {
filters := []nostr.Filter{{
Authors: []string{pubkey},
Kinds: []int{nostr.KindFollowList},
}}
var pubkeys []string
for ev := range pool.SubManyEose(ctx, getRelays(), filters) {
for _, contact := range ev.Event.Tags.GetAll([]string{"p"}) {
if len(contact) < 2 {
continue
}
pubkeys = append(pubkeys, contact[1])
}
}
return pubkeys
}