bring in khatru and eventstore.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# Generating custom live events
|
||||
|
||||
Suppose you want to generate a new event every time a goal is scored on some soccer game and send that to all clients subscribed to a given game according to a tag `t`.
|
||||
|
||||
We'll assume you'll be polling some HTTP API that gives you the game's current score, and that in your `main` function you'll start the function that does the polling:
|
||||
|
||||
```go
|
||||
func main () {
|
||||
// other stuff here
|
||||
relay := khatru.NewRelay()
|
||||
|
||||
go startPollingGame(relay)
|
||||
// other stuff here
|
||||
}
|
||||
|
||||
type GameStatus struct {
|
||||
TeamA int `json:"team_a"`
|
||||
TeamB int `json:"team_b"`
|
||||
}
|
||||
|
||||
func startPollingGame(relay *khatru.Relay) {
|
||||
current := GameStatus{0, 0}
|
||||
|
||||
for {
|
||||
newStatus, err := fetchGameStatus()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if newStatus.TeamA > current.TeamA {
|
||||
// team A has scored a goal, here we generate an event
|
||||
evt := nostr.Event{
|
||||
CreatedAt: nostr.Now(),
|
||||
Kind: 1,
|
||||
Content: "team A has scored!",
|
||||
Tags: nostr.Tags{{"t", "this-game"}}
|
||||
}
|
||||
evt.Sign(global.RelayPrivateKey)
|
||||
// calling BroadcastEvent will send the event to everybody who has been listening for tag "t=[this-game]"
|
||||
// there is no need to do any code to keep track of these clients or who is listening to what, khatru
|
||||
// does that already in the background automatically
|
||||
relay.BroadcastEvent(evt)
|
||||
|
||||
// just calling BroadcastEvent won't cause this event to be be stored,
|
||||
// if for any reason you want to store these events you must call the store functions manually
|
||||
for _, store := range relay.StoreEvent {
|
||||
store(context.TODO(), evt)
|
||||
}
|
||||
}
|
||||
if newStatus.TeamB > current.TeamB {
|
||||
// same here, if team B has scored a goal
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchGameStatus() (GameStatus, error) {
|
||||
// implementation of calling some external API goes here
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# Generating events on the fly from a non-Nostr data-source
|
||||
|
||||
Suppose you want to serve events with the weather data for periods in the past. All you have is a big CSV file with the data.
|
||||
|
||||
Then you get a query like `{"#g": ["d6nvp"], "since": 1664074800, "until": 1666666800, "kind": 10774}`, imagine for a while that kind `10774` means weather data.
|
||||
|
||||
First you do some geohashing calculation to discover that `d6nvp` corresponds to Willemstad, Curaçao, then you query your XML file for the Curaçao weather data for the given period -- from `2022-09-25` to `2022-10-25`, then you return the events corresponding to such query, signed on the fly:
|
||||
|
||||
```go
|
||||
func main () {
|
||||
// other stuff here
|
||||
relay := khatru.NewRelay()
|
||||
|
||||
relay.QueryEvents = append(relay.QueryEvents,
|
||||
handleWeatherQuery,
|
||||
)
|
||||
// other stuff here
|
||||
}
|
||||
|
||||
func handleWeatherQuery(ctx context.Context, filter nostr.Filter) (ch chan *nostr.Event, err error) {
|
||||
if filter.Kind != 10774 {
|
||||
// this function only handles kind 10774, if the query is for something else we return
|
||||
// a nil channel, which corresponds to no results
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
file, err := os.Open("weatherdata.xml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("we have lost our file: %w", err)
|
||||
}
|
||||
|
||||
// QueryEvents functions are expected to return a channel
|
||||
ch := make(chan *nostr.Event)
|
||||
|
||||
// and they can do their query asynchronously, emitting events to the channel as they come
|
||||
go func () {
|
||||
defer file.Close()
|
||||
|
||||
// we're going to do this for each tag in the filter
|
||||
gTags, _ := filter.Tags["g"]
|
||||
for _, gTag := range gTags {
|
||||
// translate geohash into city name
|
||||
citName, err := geohashToCityName(gTag)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
for {
|
||||
record, err := reader.Read()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// ensure we're only getting records for Willemstad
|
||||
if cityName != record[0] {
|
||||
continue
|
||||
}
|
||||
|
||||
date, _ := time.Parse("2006-01-02", record[1])
|
||||
ts := nostr.Timestamp(date.Unix())
|
||||
if ts > filter.Since && ts < filter.Until {
|
||||
// we found a record that matches the filter, so we make
|
||||
// an event on the fly and return it
|
||||
evt := nostr.Event{
|
||||
CreatedAt: ts,
|
||||
Kind: 10774,
|
||||
Tags: nostr.Tags{
|
||||
{"temperature", record[2]},
|
||||
{"condition", record[3]},
|
||||
}
|
||||
}
|
||||
evt.Sign(global.RelayPrivateKey)
|
||||
ch <- evt
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
```
|
||||
|
||||
Beware, the code above is inefficient and the entire approach is not very smart, it's meant just as an example.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# Generating `khatru` relays dynamically and serving them from the same path
|
||||
|
||||
Suppose you want to expose a different relay interface depending on the subdomain that is accessed. I don't know, maybe you want to serve just events with pictures on `pictures.example.com` and just events with audio files on `audios.example.com`; maybe you want just events in English on `en.example.com` and just examples in Portuguese on `pt.example.com`, there are many possibilities.
|
||||
|
||||
You could achieve that with a scheme like the following
|
||||
|
||||
```go
|
||||
var topLevelHost = "example.com"
|
||||
var mainRelay = khatru.NewRelay() // we're omitting all the configuration steps for brevity
|
||||
var subRelays = xsync.NewMapOf[string, *khatru.Relay]()
|
||||
|
||||
func main () {
|
||||
handler := http.HandlerFunc(dynamicRelayHandler)
|
||||
|
||||
log.Printf("listening at http://0.0.0.0:8080")
|
||||
http.ListenAndServe("0.0.0.0:8080", handler)
|
||||
}
|
||||
|
||||
func dynamicRelayHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var relay *khatru.Relay
|
||||
subdomain := r.Host[0 : len(topLevelHost)-len(topLevelHost)]
|
||||
if subdomain == "" {
|
||||
// no subdomain, use the main top-level relay
|
||||
relay = mainRelay
|
||||
} else {
|
||||
// call on subdomain, so get a dynamic relay
|
||||
subdomain = subdomain[0 : len(subdomain)-1] // remove dangling "."
|
||||
// get a dynamic relay
|
||||
relay, _ = subRelays.LoadOrCompute(subdomain, func () *khatru.Relay {
|
||||
return makeNewRelay(subdomain)
|
||||
})
|
||||
}
|
||||
|
||||
relay.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func makeNewRelay (subdomain string) *khatru.Relay {
|
||||
// somehow use the subdomain to generate a relay with specific configurations
|
||||
relay := khatru.NewRelay()
|
||||
switch subdomain {
|
||||
case "pictures":
|
||||
// relay configuration shenanigans go here
|
||||
case "audios":
|
||||
// relay configuration shenanigans go here
|
||||
case "en":
|
||||
// relay configuration shenanigans go here
|
||||
case "pt":
|
||||
// relay configuration shenanigans go here
|
||||
}
|
||||
return relay
|
||||
}
|
||||
```
|
||||
|
||||
In practice you could come up with a way that allows all these dynamic relays to share a common underlying datastore, but this is out of the scope of this example.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
## Querying events from Google Drive
|
||||
|
||||
Suppose you have a bunch of events stored in text files on Google Drive and you want to serve them as a relay. You could just store each event as a separate file and use the native Google Drive search to match the queries when serving requests. It would probably not be as fast as using local database, but it would work.
|
||||
|
||||
```go
|
||||
func main () {
|
||||
// other stuff here
|
||||
relay := khatru.NewRelay()
|
||||
|
||||
relay.StoreEvent = append(relay.StoreEvent, handleEvent)
|
||||
relay.QueryEvents = append(relay.QueryEvents, handleQuery)
|
||||
// other stuff here
|
||||
}
|
||||
|
||||
func handleEvent(ctx context.Context, event *nostr.Event) error {
|
||||
// store each event as a file on google drive
|
||||
_, err := gdriveService.Files.Create(googledrive.CreateOptions{
|
||||
Name: event.ID, // with the name set to their id
|
||||
Body: event.String(), // the body as the full event JSON
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func handleQuery(ctx context.Context, filter nostr.Filter) (ch chan *nostr.Event, err error) {
|
||||
// QueryEvents functions are expected to return a channel
|
||||
ch := make(chan *nostr.Event)
|
||||
|
||||
// and they can do their query asynchronously, emitting events to the channel as they come
|
||||
go func () {
|
||||
if len(filter.IDs) > 0 {
|
||||
// if the query is for ids we can do a simpler name match
|
||||
for _, id := range filter.IDS {
|
||||
results, _ := gdriveService.Files.List(googledrive.ListOptions{
|
||||
Q: fmt.Sprintf("name = '%s'", id)
|
||||
})
|
||||
if len(results) > 0 {
|
||||
var evt nostr.Event
|
||||
json.Unmarshal(results[0].Body, &evt)
|
||||
ch <- evt
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// otherwise we use the google-provided search and hope it will catch tags that are in the event body
|
||||
for tagName, tagValues := range filter.Tags {
|
||||
results, _ := gdriveService.Files.List(googledrive.ListOptions{
|
||||
Q: fmt.Sprintf("fullText contains '%s'", tagValues)
|
||||
})
|
||||
for _, result := range results {
|
||||
var evt nostr.Event
|
||||
json.Unmarshal(results[0].Body, &evt)
|
||||
if filter.Match(evt) {
|
||||
ch <- evt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
```
|
||||
|
||||
(Disclaimer: since I have no idea of how to properly use the Google Drive API this interface is entirely made up.)
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# Implementing NIP-50 `search` support
|
||||
|
||||
The [`nostr.Filter` type](https://pkg.go.dev/github.com/nbd-wtf/go-nostr#Filter) has a `Search` field, so you basically just has to handle that if it's present.
|
||||
|
||||
It can be tricky to implement fulltext search properly though, so some [eventstores](../core/eventstore) implement it natively, such as [Bluge](https://pkg.go.dev/github.com/fiatjaf/eventstore/bluge), [OpenSearch](https://pkg.go.dev/github.com/fiatjaf/eventstore/opensearch) and [ElasticSearch](https://pkg.go.dev/github.com/fiatjaf/eventstore/elasticsearch) (although for the last two you'll need an instance of these database servers running, while with Bluge it's embedded).
|
||||
|
||||
If you have any of these you can just use them just like any other eventstore:
|
||||
|
||||
```go
|
||||
func main () {
|
||||
// other stuff here
|
||||
|
||||
normal := &lmdb.LMDBBackend{Path: "data"}
|
||||
os.MkdirAll(normal.Path, 0755)
|
||||
if err := normal.Init(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
search := bluge.BlugeBackend{Path: "search", RawEventStore: normal}
|
||||
if err := search.Init(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
relay.StoreEvent = append(relay.StoreEvent, normal.SaveEvent, search.SaveEvent)
|
||||
relay.QueryEvents = append(relay.QueryEvents, normal.QueryEvents, search.QueryEvents)
|
||||
relay.DeleteEvent = append(relay.DeleteEvent, normal.DeleteEvent, search.DeleteEvent)
|
||||
|
||||
// other stuff here
|
||||
}
|
||||
```
|
||||
|
||||
Note that in this case we're using the [LMDB](https://pkg.go.dev/github.com/fiatjaf/eventstore/lmdb) adapter for normal queries and it explicitly rejects any filter that contains a `Search` field, while [Bluge](https://pkg.go.dev/github.com/fiatjaf/eventstore/bluge) rejects any filter _without_ a `Search` value, which make them pair well together.
|
||||
|
||||
Other adapters, like [SQLite](https://pkg.go.dev/github.com/fiatjaf/eventstore/sqlite3), implement search functionality on their own, so if you don't want to use that you would have to have a middleware between, like:
|
||||
|
||||
```go
|
||||
relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent, search.SaveEvent)
|
||||
relay.QueryEvents = append(relay.QueryEvents, func (ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
|
||||
if len(filter.Search) > 0 {
|
||||
return search.QueryEvents(ctx, filter)
|
||||
} else {
|
||||
filterNoSearch := filter
|
||||
filterNoSearch.Search = ""
|
||||
return normal.QueryEvents(ctx, filterNoSearch)
|
||||
}
|
||||
})
|
||||
```
|
||||
Reference in New Issue
Block a user