Replacable Events working

This commit is contained in:
@s.roertgen 2025-03-27 08:12:16 +01:00
parent ad554fbba8
commit 49d0c7ff55
3 changed files with 696 additions and 42 deletions

61
main.go
View file

@ -3,31 +3,33 @@ package main
import (
"context"
"fmt"
"net/http"
"log"
"net/http"
"github.com/fiatjaf/eventstore/badger"
"github.com/fiatjaf/khatru"
"github.com/nbd-wtf/go-nostr"
)
var typesense string = "http://localhost:8108"
const (
apiKey string = "xyz"
typesenseHost string = "http://localhost:8108"
collectionName string = "amb-test"
)
func main() {
fmt.Println("Hello, Go!")
err:= CheckOrCreateCollection("amb-test")
err := CheckOrCreateCollection("amb-test")
if err != nil {
log.Fatalf("Failed to check/create collection: %v", err)
}
relay := khatru.NewRelay()
relay.Info.Name = "my relay"
// relay.Info.PubKey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
relay.Info.Description = "this is my custom relay"
// relay.Info.Icon = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fliquipedia.net%2Fcommons%2Fimages%2F3%2F35%2FSCProbe.jpg&f=1&nofb=1&ipt=0cbbfef25bce41da63d910e86c3c343e6c3b9d63194ca9755351bb7c2efa3359&ipo=images"
db := badger.BadgerBackend{Path: "/tmp/khatru-badgern-tmp"}
if err := db.Init(); err != nil {
relay.Info.Name = "my typesense relay"
// tsRelay.Info.PubKey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
relay.Info.Description = "this is the typesense custom relay"
// tsRelay.Info.Icon = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fliquipedia.net%2Fcommons%2Fimages%2F3%2F35%2FSCProbe.jpg&f=1&nofb=1&ipt=0cbbfef25bce41da63d910e86c3c343e6c3b9d63194ca9755351bb7c2efa3359&ipo=images"
dbts := badger.BadgerBackend{Path: "/tmp/khatru-badgern-tmp-2"}
if err := dbts.Init(); err != nil {
panic(err)
}
@ -35,27 +37,46 @@ func main() {
khatru.RequestAuth(ctx)
})
relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent, handleEvent)
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents, handleQuery)
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent)
relay.StoreEvent = append(relay.StoreEvent)
relay.QueryEvents = append(relay.QueryEvents, handleQuery)
relay.CountEvents = append(relay.CountEvents, dbts.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, dbts.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, dbts.ReplaceEvent, handleEvent) // use badger for counting events -> TODO switch to typesense?
relay.Negentropy = true
relay.RejectEvent = append(relay.RejectEvent,
func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
if event.Kind != 30142 {
return true, "we don't allow these kinds here. It's a 30142 only place."
}
return false, ""
},
)
fmt.Println("running on :3334")
http.ListenAndServe(":3334", relay)
}
func handleEvent(ctx context.Context, event *nostr.Event) error {
fmt.Println("got one", event)
// TODO index md-event
IndexNostrEvent(collectionName, event)
return nil
}
func handleQuery(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
ch := make(chan *nostr.Event)
// TODO do stuff with search nips and look for an edufeed or amb something in the tags
fmt.Println("a query!", filter)
nostrs, err := SearchResources(collectionName, filter.Search)
if err != nil {
log.Printf("Search failed: %v", err)
return ch, err
}
go func() {
for _, evt := range nostrs {
ch <- &evt
}
close(ch)
}()
return ch, nil
}

326
nostramb.go Normal file
View file

@ -0,0 +1,326 @@
package main
import (
"encoding/json"
"github.com/nbd-wtf/go-nostr"
)
// BaseEntity contains common fields used across many entity types
type BaseEntity struct {
Type string `json:"type,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
}
type ControlledVocabulary struct {
Type string `json:"type,omitempty"`
ID string `json:"id"`
PrefLabel string `json:"prefLabel"`
InLanguage string `json:"inLanguage,omitempty"`
}
// LanguageEntity adds language support to entities
type LanguageEntity struct {
InLanguage string `json:"inLanguage,omitempty"`
}
// LabeledEntity adds prefLabel to entities
type LabeledEntity struct {
PrefLabel string `json:"prefLabel,omitempty"`
}
// About represents a topic or subject
type About struct {
ControlledVocabulary
}
// Creator represents the creator of content
type Creator struct {
BaseEntity
Affiliation *Affiliation `json:"affiliation,omitempty"`
HonoricPrefix string `json:"honoricPrefix,omitempty"`
}
// Contributor represents someone who contributed to the content
type Contributor struct {
BaseEntity
HonoricPrefix string `json:"honoricPrefix,omitempty"`
}
// Publisher represents the publisher of the content
type Publisher struct {
BaseEntity
}
// Funder represents an entity that funded the content
type Funder struct {
BaseEntity
}
// Affiliation represents an organization affiliation
type Affiliation struct {
BaseEntity
}
// ConditionsOfAccess represents access conditions
type ConditionsOfAccess struct {
ControlledVocabulary
}
// LearningResourceType categorizes the learning resource
type LearningResourceType struct {
ControlledVocabulary
}
// Audience represents the target audience
type Audience struct {
ControlledVocabulary
}
// Teaches represents what the content teaches
type Teaches struct {
ID string `json:"id"`
LabeledEntity
LanguageEntity
}
// Assesses represents what the content assesses
type Assesses struct {
ID string `json:"id"`
LabeledEntity
LanguageEntity
}
// CompetencyRequired represents required competencies
type CompetencyRequired struct {
ID string `json:"id"`
LabeledEntity
LanguageEntity
}
// EducationalLevel represents the educational level
type EducationalLevel struct {
ControlledVocabulary
}
// InteractivityType represents the type of interactivity
type InteractivityType struct {
ControlledVocabulary
}
// IsBasedOn represents a reference to source material
type IsBasedOn struct {
Type string `json:"type,omitempty"`
Name string `json:"name"`
Creator *Creator `json:"creator,omitempty"`
License *License `json:"license,omitempty"`
}
// IsPartOf represents a parent relationship
type IsPartOf struct {
BaseEntity
}
type HasPart struct {
BaseEntity
}
// Trailer represents a media trailer
type Trailer struct {
Type string `json:"type"`
ContentUrl string `json:"contentUrl"`
EncodingFormat string `json:"encodingFormat"`
ContentSize string `json:"contentSize,omitempty"`
Sha256 string `json:"sha256,omitempty"`
EmbedUrl string `json:"embedUrl,omitempty"`
Bitrate string `json:"bitrate,omitempty"`
}
// License represents the content license
type License struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
}
// NostrMetadata contains Nostr-specific metadata
type NostrMetadata struct {
EventID string `json:"eventID"`
EventKind int `json:"eventKind"`
EventPubKey string `json:"eventPubKey"`
EventSig string `json:"eventSignature"`
EventCreatedAt nostr.Timestamp `json:"eventCreatedAt"`
EventContent string `json:"eventContent"`
EventRaw string `json:"eventRaw"`
}
// AMBMetadata represents the full metadata structure
type AMBMetadata struct {
// Document ID, same a d-tag
ID string `json:"id"`
D string `json:"d"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
About []*About `json:"about,omitempty"`
Keywords []string `json:"keywords,omitempty"`
InLanguage []string `json:"inLanguage,omitempty"`
Image string `json:"image,omitempty"`
Trailer []*Trailer `json:"trailer,omitempty"`
// Provenience
Creator []*Creator `json:"creator,omitempty"`
Contributor []*Contributor `json:"contributor,omitempty"`
DateCreated string `json:"dateCreated,omitempty"`
DatePublished string `json:"datePublished,omitempty"`
DateModified string `json:"dateModified,omitempty"`
Publisher []*Publisher `json:"publisher,omitempty"`
Funder []*Funder `json:"funder,omitempty"`
// Costs and Rights
IsAccessibleForFree bool `json:"isAccessibleForFree,omitempty"`
License *License `json:"license,omitempty"`
ConditionsOfAccess *ConditionsOfAccess `json:"conditionsOfAccess,omitempty"`
// Educational metadata
LearningResourceType []*LearningResourceType `json:"learningResourceType,omitempty"`
Audience []*Audience `json:"audience,omitempty"`
Teaches []*Teaches `json:"teaches,omitempty"`
Assesses []*Assesses `json:"assesses,omitempty"`
CompetencyRequired []*CompetencyRequired `json:"competencyRequired,omitempty"`
EducationalLevel []*EducationalLevel `json:"educationalLevel,omitempty"`
InteractivityType *InteractivityType `json:"interactivityType,omitempty"`
// Relation
IsBasedOn []*IsBasedOn `json:"isBasedOn,omitempty"`
IsPartOf []*IsPartOf `json:"isPartOf,omitempty"`
HasPart []*HasPart `json:"hasPart,omitempty"`
// Technical
Duration string `json:"duration,omitempty"`
// TODO Encoding ``
// TODO Caption
// Nostr integration
NostrMetadata `json:",inline"`
}
// converts a nostr event to stringified JSON
func eventToStringifiedJSON(event *nostr.Event) (string, error) {
jsonData, err := json.Marshal(event)
if err != nil {
return "", err
}
jsonString := string(jsonData)
return jsonString, err
}
// NostrToAMB converts a Nostr event of kind 30142 to AMB metadata
func NostrToAMB(event *nostr.Event) (*AMBMetadata, error) {
eventRaw, _ := eventToStringifiedJSON(event)
amb := &AMBMetadata{
Type: "LearningResource",
NostrMetadata: NostrMetadata{
EventID: event.ID,
EventPubKey: event.PubKey,
EventContent: event.Content,
EventCreatedAt: event.CreatedAt,
EventKind: event.Kind,
EventSig: event.Sig,
EventRaw: eventRaw,
},
}
for _, tag := range event.Tags {
if len(tag) < 2 {
continue
}
// TODO alle Attribute durchgehen für das parsen
switch tag[0] {
case "d":
if len(tag) >= 2 {
amb.ID = tag[1]
amb.D = tag[1]
}
case "name":
if len(tag) >= 2 {
amb.Name = tag[1]
}
case "description":
if len(tag) >= 2 {
amb.Description = tag[1]
}
case "creator":
if len(tag) >= 2 {
creator := &Creator{}
creator.Name = tag[1]
if len(tag) >= 3 {
creator.ID = tag[2]
}
if len(tag) >= 4 {
creator.Type = tag[3]
}
amb.Creator = append(amb.Creator, creator)
}
case "image":
if len(tag) >= 2 {
amb.Image = tag[1]
}
case "about":
if len(tag) >= 3 {
subject := &About{}
subject.PrefLabel = tag[1]
subject.InLanguage = tag[2]
if len(tag) >= 4 {
subject.ID = tag[3]
}
amb.About = append(amb.About, subject)
}
case "learningResourceType":
if len(tag) >= 3 {
lrt := &LearningResourceType{}
lrt.PrefLabel = tag[1]
lrt.InLanguage = tag[2]
if len(tag) >= 4 {
lrt.ID = tag[3]
}
amb.LearningResourceType = append(amb.LearningResourceType, lrt)
}
case "inLanguage":
if len(tag) >= 2 {
amb.InLanguage = append(amb.InLanguage, tag[1])
}
case "keywords":
if len(tag) >= 2 {
amb.Keywords = tag[1:]
}
case "license":
if len(tag) >= 3 {
amb.License = &License{}
amb.License.ID = tag[1]
amb.License.Name = tag[2]
}
case "datePublished":
if len(tag) >= 2 {
amb.DatePublished = tag[1]
}
}
}
return amb, nil
}
// converts a stringified JSON event to a nostr.Event
func StringifiedJSONToNostrEvent(jsonString string) (nostr.Event, error) {
var event nostr.Event
err := json.Unmarshal([]byte(jsonString), &event)
if err != nil {
return nostr.Event{}, err
}
return event, nil
}

View file

@ -7,18 +7,18 @@ import (
"io"
"log"
"net/http"
)
"net/url"
"regexp"
"strings"
const (
typesenseHost = "http://localhost:8108"
apiKey = "xyz"
// collectionName = "amb-test"
"github.com/nbd-wtf/go-nostr"
)
type CollectionSchema struct {
Name string `json:"name"`
Fields []Field `json:"fields"`
DefaultSortingField string `json:"default_sorting_field"`
EnableNestedFields bool `json:"enable_nested_fields"`
}
type Field struct {
@ -28,6 +28,13 @@ type Field struct {
Optional bool `json:"optional,omitempty"`
}
type SearchResponse struct {
Found int `json:"found"`
Hits []map[string]interface{} `json:"hits"`
Page int `json:"page"`
Request map[string]interface{} `json:"request"`
}
// CheckOrCreateCollection checks if a collection exists and creates it if it doesn't
func CheckOrCreateCollection(collectionName string) error {
exists, err := collectionExists(collectionName)
@ -79,26 +86,67 @@ func collectionExists(name string) (bool, error) {
return true, nil
}
// create a typesense collection
func createCollection(name string) error {
url := fmt.Sprintf("%s/collections", typesenseHost)
schema := CollectionSchema{
Name: name,
Fields: []Field{
{
Name: "name",
Type: "string",
// Base information
{Name: "id", Type: "string"},
{Name: "d", Type: "string"},
{Name: "type", Type: "string"},
{Name: "name", Type: "string"},
{Name: "description", Type: "string", Optional: true},
{Name: "about", Type: "object[]", Optional: true},
{Name: "keywords", Type: "string[]", Optional: true},
{Name: "inLanguage", Type: "string[]", Optional: true},
{Name: "image", Type: "string", Optional: true},
{Name: "trailer", Type: "object[]", Optional: true},
// Provenience
{Name: "creator", Type: "object[]", Optional: true},
{Name: "contributor", Type: "object[]", Optional: true},
{Name: "dateCreated", Type: "string", Optional: true},
{Name: "datePublished", Type: "string", Optional: true},
{Name: "dateModified", Type: "string", Optional: true},
{Name: "publisher", Type: "object[]", Optional: true},
{Name: "funder", Type: "object[]", Optional: true},
// Costs and Rights
{Name: "isAccessibleForFree", Type: "bool", Optional: true},
{Name: "license", Type: "object", Optional: true},
{Name: "conditionsOfAccess", Type: "object", Optional: true},
// Educational Metadata
{Name: "learningResourceType", Type: "object[]", Optional: true},
{Name: "audience", Type: "object[]", Optional: true},
{Name: "teaches", Type: "object[]", Optional: true},
{Name: "assesses", Type: "object[]", Optional: true},
{Name: "competencyRequired", Type: "object[]", Optional: true},
{Name: "educationalLevel", Type: "object[]", Optional: true},
{Name: "interactivityType", Type: "object", Optional: true},
// Relation
{Name: "isBasedOn", Type: "object[]", Optional: true},
{Name: "isPartOf", Type: "object[]", Optional: true},
{Name: "hasPart", Type: "object[]", Optional: true},
// Technical
{Name: "duration", Type: "string", Optional: true},
// Nostr Event
{Name: "eventID", Type: "string"},
{Name: "eventKind", Type: "int32"},
{Name: "eventPubKey", Type: "string"},
{Name: "eventSignature", Type: "string"},
{Name: "eventCreatedAt", Type: "int64"},
{Name: "eventContent", Type: "string"},
{Name: "eventRaw", Type: "string"},
},
{
Name: "description",
Type: "string",
},
{
Name: "event_date_created",
Type: "int64",
},
},
DefaultSortingField: "event_date_created",
DefaultSortingField: "eventCreatedAt",
EnableNestedFields: true,
}
jsonData, err := json.Marshal(schema)
@ -128,3 +176,262 @@ func createCollection(name string) error {
return nil
}
// IndexNostrEvent converts a Nostr event to AMB metadata and indexes it in Typesense
func IndexNostrEvent(collectionName string, event *nostr.Event) error {
ambData, err := NostrToAMB(event)
if err != nil {
return fmt.Errorf("error converting Nostr event to AMB metadata: %v", err)
}
// check if event is already there, if so replace it, else index it
alreadyIndexed, err := eventAlreadyIndexed(collectionName, ambData)
return indexDocument(collectionName, ambData, alreadyIndexed)
}
func eventAlreadyIndexed(collectionName string, doc *AMBMetadata) (bool, error) {
url := fmt.Sprintf("%s/collections/%s/documents/%s", typesenseHost, collectionName, url.QueryEscape(doc.ID))
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return false, err
}
req.Header.Set("X-TYPESENSE-API-KEY", apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return false, fmt.Errorf("failed to index document, status: %d, body: %s", resp.StatusCode, string(body))
}
return true, err
}
// Index a document in Typesense
func indexDocument(collectionName string, doc *AMBMetadata, update bool) error {
if update {
fmt.Println("updating", doc)
} else {
fmt.Println("indexing", doc)
}
url := fmt.Sprintf("%s/collections/%s/documents", typesenseHost, collectionName)
jsonData, err := json.Marshal(doc)
if err != nil {
return err
}
method := http.MethodPost
if update {
method = http.MethodPatch
}
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("X-TYPESENSE-API-KEY", apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
// Do the request
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("failed to index document, status: %d, body: %s", resp.StatusCode, string(body))
}
return nil
}
// SearchQuery represents a parsed search query with raw terms and field filters
type SearchQuery struct {
RawTerms []string
FieldFilters map[string]string
}
// ParseSearchQuery parses a search string with support for quoted terms and field:value pairs
func ParseSearchQuery(searchStr string) SearchQuery {
var query SearchQuery
query.RawTerms = []string{}
query.FieldFilters = make(map[string]string)
// Regular expression to match quoted strings and field:value pairs
// This regex handles:
// 1. Quoted strings (preserving spaces and everything inside)
// 2. Field:value pairs
// 3. Regular words
re := regexp.MustCompile(`"([^"]+)"|(\S+\.\S+):(\S+)|(\S+)`)
matches := re.FindAllStringSubmatch(searchStr, -1)
for _, match := range matches {
if match[1] != "" {
// This is a quoted string, add it to raw terms
query.RawTerms = append(query.RawTerms, match[1])
} else if match[2] != "" && match[3] != "" {
// This is a field:value pair
fieldName := match[2]
fieldValue := match[3]
query.FieldFilters[fieldName] = fieldValue
} else if match[4] != "" {
// This is a regular word, check if it's a simple field:value
parts := strings.SplitN(match[4], ":", 2)
if len(parts) == 2 && !strings.Contains(parts[0], ".") {
// Simple field:value without dot notation
query.FieldFilters[parts[0]] = parts[1]
} else {
// Regular search term
query.RawTerms = append(query.RawTerms, match[4])
}
}
}
return query
}
// BuildTypesenseQuery builds a Typesense search query from a parsed SearchQuery
func BuildTypesenseQuery(query SearchQuery) (string, map[string]string, error) {
// Join raw terms for the main query
mainQuery := strings.Join(query.RawTerms, " ")
// Parameters for filter_by and other Typesense parameters
params := make(map[string]string)
// Build filter expressions for field filters
var filterExpressions []string
for field, value := range query.FieldFilters {
// Handle special fields with dot notation
if strings.Contains(field, ".") {
parts := strings.SplitN(field, ".", 2)
fieldName := parts[0]
subField := parts[1]
filterExpressions = append(filterExpressions, fmt.Sprintf("%s.%s:%s", fieldName, subField, value))
} else {
filterExpressions = append(filterExpressions, fmt.Sprintf("%s:%s", field, value))
}
}
// Combine all filter expressions
if len(filterExpressions) > 0 {
params["filter_by"] = strings.Join(filterExpressions, " && ")
}
return mainQuery, params, nil
}
// SearchResources searches for resources and returns both the AMB metadata and converted Nostr events
func SearchResources(collectionName, searchStr string) ([]nostr.Event, error) {
parsedQuery := ParseSearchQuery(searchStr)
mainQuery, params, err := BuildTypesenseQuery(parsedQuery)
if err != nil {
return nil, fmt.Errorf("error building Typesense query: %v", err)
}
// URL encode the main query
encodedQuery := url.QueryEscape(mainQuery)
// Default fields to search in
queryBy := "name,description"
// Start building the search URL
searchURL := fmt.Sprintf("%s/collections/%s/documents/search?q=%s&query_by=%s",
typesenseHost, collectionName, encodedQuery, queryBy)
// Add additional parameters
for key, value := range params {
searchURL += fmt.Sprintf("&%s=%s", key, url.QueryEscape(value))
}
// Debug information
fmt.Printf("Search URL: %s\n", searchURL)
// Create request
req, err := http.NewRequest(http.MethodGet, searchURL, nil)
if err != nil {
return nil, fmt.Errorf("error creating search request: %v", err)
}
req.Header.Set("X-TYPESENSE-API-KEY", apiKey)
// Execute the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error executing search request: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %v", err)
}
// Check for errors
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("search failed with status code %d: %s", resp.StatusCode, string(body))
}
// Parse the search response
var searchResponse SearchResponse
if err := json.Unmarshal(body, &searchResponse); err != nil {
return nil, fmt.Errorf("error parsing search response: %v", err)
}
nostrResults := make([]nostr.Event, 0, len(searchResponse.Hits))
for _, hit := range searchResponse.Hits {
// Extract the document from the hit
docMap, ok := hit["document"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid document format in search results")
}
// Convert the map to AMB metadata
docJSON, err := json.Marshal(docMap)
if err != nil {
return nil, fmt.Errorf("error marshaling document: %v", err)
}
var ambData AMBMetadata
if err := json.Unmarshal(docJSON, &ambData); err != nil {
return nil, fmt.Errorf("error unmarshaling to AMBMetadata: %v", err)
}
// Convert the AMB metadata to a Nostr event
nostrEvent, err := StringifiedJSONToNostrEvent(ambData.EventRaw)
if err != nil {
fmt.Printf("Warning: failed to convert AMB to Nostr: %v\n", err)
continue
}
nostrResults = append(nostrResults, nostrEvent)
}
// Print the number of results for logging
fmt.Printf("Found %d results\n",
len(nostrResults))
return nostrResults, nil
}