package main import ( "bufio" "context" "fmt" "os" "path/filepath" "strconv" "strings" "time" "fiatjaf.com/nostr" "fiatjaf.com/nostr/nip19" "fiatjaf.com/nostr/sdk/hints" "github.com/fatih/color" "github.com/markusmobius/go-dateparser" "github.com/urfave/cli/v3" ) var spell = &cli.Command{ Name: "spell", Usage: "downloads a spell event and executes its REQ request", ArgsUsage: "[nevent_code]", Description: `fetches a spell event (kind 777) and executes REQ command encoded in its tags.`, Flags: append(defaultKeyFlags, &cli.UintFlag{ Name: "outbox-relays-per-pubkey", Aliases: []string{"n"}, Usage: "number of outbox relays to use for each pubkey", Value: 3, }, ), Action: func(ctx context.Context, c *cli.Command) error { // load history from file var history []SpellHistoryEntry historyPath, err := getSpellHistoryPath() if err == nil { file, err := os.Open(historyPath) if err == nil { defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { var entry SpellHistoryEntry if err := json.Unmarshal([]byte(scanner.Text()), &entry); err != nil { continue // skip invalid entries } history = append(history, entry) } } } if c.Args().Len() == 0 { log("recent spells:\n") for i, entry := range history { if i >= 10 { break } displayName := entry.Name if displayName == "" { displayName = entry.Content if len(displayName) > 28 { displayName = displayName[:27] + "…" } } if displayName != "" { displayName = displayName + ": " } desc := entry.Content if len(desc) > 50 { desc = desc[0:49] + "…" } lastUsed := entry.LastUsed.Format("2006-01-02 15:04") stdout(fmt.Sprintf(" %s %s%s - %s\n", color.BlueString(entry.Identifier), displayName, color.YellowString(lastUsed), desc, )) } return nil } // decode nevent to get the spell event var pointer nostr.EventPointer identifier := c.Args().First() prefix, value, err := nip19.Decode(identifier) if err == nil { if prefix != "nevent" { return fmt.Errorf("expected nevent code, got %s", prefix) } pointer = value.(nostr.EventPointer) } else { // search our history for _, entry := range history { if entry.Identifier == identifier { pointer = entry.Pointer break } } } if pointer.ID == nostr.ZeroID { return fmt.Errorf("invalid spell reference") } // fetch spell relays := pointer.Relays if pointer.Author != nostr.ZeroPK { for _, url := range relays { sys.Hints.Save(pointer.Author, nostr.NormalizeURL(url), hints.LastInHint, nostr.Now()) } relays = append(relays, sys.FetchOutboxRelays(ctx, pointer.Author, 3)...) } spell := sys.Pool.QuerySingle(ctx, relays, nostr.Filter{IDs: []nostr.ID{pointer.ID}}, nostr.SubscriptionOptions{Label: "nak-spell-f"}) if spell == nil { return fmt.Errorf("spell event not found") } if spell.Kind != 777 { return fmt.Errorf("event is not a spell (expected kind 777, got %d)", spell.Kind) } // parse spell tags to build REQ filter spellFilter, err := buildSpellReq(ctx, c, spell.Tags) if err != nil { return fmt.Errorf("failed to parse spell tags: %w", err) } // determine relays to query var spellRelays []string var outbox bool relaysTag := spell.Event.Tags.Find("relays") if relaysTag == nil { // if this tag doesn't exist assume $outbox relaysTag = nostr.Tag{"relays", "$outbox"} } for i := 1; i < len(relaysTag); i++ { switch relaysTag[i] { case "$outbox": outbox = true default: relays = append(relays, relaysTag[i]) } } stream := !spell.Tags.Has("close-on-eose") // fill in the author if we didn't have it pointer.Author = spell.PubKey // add to history before execution { idStr := nip19.EncodeNevent(spell.ID, nil, nostr.ZeroPK) identifier = "spell" + idStr[len(idStr)-7:] nameTag := spell.Tags.Find("name") var name string if nameTag != nil { name = nameTag[1] } if len(history) > 100 { history = history[:100] } // write back to file file, err := os.Create(historyPath) if err != nil { return err } data, _ := json.Marshal(SpellHistoryEntry{ Identifier: identifier, Name: name, Content: spell.Content, LastUsed: time.Now(), Pointer: pointer, }) file.Write(data) file.Write([]byte{'\n'}) for i, entry := range history { // limit history size (keep last 100) if i == 100 { break } data, _ := json.Marshal(entry) file.Write(data) file.Write([]byte{'\n'}) } file.Close() logverbose("executing %s: %s relays=%v outbox=%v stream=%v\n", identifier, spellFilter, spellRelays, outbox, stream) } // execute performReq(ctx, spellFilter, spellRelays, stream, outbox, c.Uint("outbox-relays-per-pubkey"), false, 0, "nak-spell") return nil }, } func buildSpellReq(ctx context.Context, c *cli.Command, tags nostr.Tags) (nostr.Filter, error) { filter := nostr.Filter{} getMe := func() (nostr.PubKey, error) { kr, _, err := gatherKeyerFromArguments(ctx, c) if err != nil { return nostr.ZeroPK, fmt.Errorf("failed to get keyer: %w", err) } pubkey, err := kr.GetPublicKey(ctx) if err != nil { return nostr.ZeroPK, fmt.Errorf("failed to get public key from keyer: %w", err) } return pubkey, nil } for _, tag := range tags { if len(tag) == 0 { continue } switch tag[0] { case "cmd": if len(tag) < 2 || tag[1] != "REQ" { return nostr.Filter{}, fmt.Errorf("only REQ commands are supported") } case "k": for i := 1; i < len(tag); i++ { if kind, err := strconv.Atoi(tag[i]); err == nil { filter.Kinds = append(filter.Kinds, nostr.Kind(kind)) } } case "authors": for i := 1; i < len(tag); i++ { switch tag[i] { case "$me": me, err := getMe() if err != nil { return nostr.Filter{}, err } filter.Authors = append(filter.Authors, me) case "$contacts": me, err := getMe() if err != nil { return nostr.Filter{}, err } for _, f := range sys.FetchFollowList(ctx, me).Items { filter.Authors = append(filter.Authors, f.Pubkey) } default: pubkey, err := nostr.PubKeyFromHex(tag[i]) if err != nil { return nostr.Filter{}, fmt.Errorf("invalid pubkey '%s' in 'authors': %w", tag[i], err) } filter.Authors = append(filter.Authors, pubkey) } } case "ids": for i := 1; i < len(tag); i++ { id, err := nostr.IDFromHex(tag[i]) if err != nil { return nostr.Filter{}, fmt.Errorf("invalid id '%s' in 'authors': %w", tag[i], err) } filter.IDs = append(filter.IDs, id) } case "tag": if len(tag) < 3 { continue } tagName := tag[1] if filter.Tags == nil { filter.Tags = make(nostr.TagMap) } for i := 2; i < len(tag); i++ { switch tag[i] { case "$me": me, err := getMe() if err != nil { return nostr.Filter{}, err } filter.Tags[tagName] = append(filter.Tags[tagName], me.Hex()) case "$contacts": me, err := getMe() if err != nil { return nostr.Filter{}, err } for _, f := range sys.FetchFollowList(ctx, me).Items { filter.Tags[tagName] = append(filter.Tags[tagName], f.Pubkey.Hex()) } default: filter.Tags[tagName] = append(filter.Tags[tagName], tag[i]) } } case "limit": if len(tag) >= 2 { if limit, err := strconv.Atoi(tag[1]); err == nil { filter.Limit = limit } } case "since": if len(tag) >= 2 { date, err := dateparser.Parse(&dateparser.Configuration{ DefaultTimezone: time.Local, CurrentTime: time.Now(), }, tag[1]) if err != nil { return nostr.Filter{}, fmt.Errorf("invalid date %s: %w", tag[1], err) } filter.Since = nostr.Timestamp(date.Time.Unix()) } case "until": if len(tag) >= 2 { date, err := dateparser.Parse(&dateparser.Configuration{ DefaultTimezone: time.Local, CurrentTime: time.Now(), }, tag[1]) if err != nil { return nostr.Filter{}, fmt.Errorf("invalid date %s: %w", tag[1], err) } filter.Until = nostr.Timestamp(date.Time.Unix()) } case "search": if len(tag) >= 2 { filter.Search = tag[1] } } } return filter, nil } func parseRelativeTime(timeStr string) (nostr.Timestamp, error) { // Handle special cases switch timeStr { case "now": return nostr.Now(), nil } // Try to parse as relative time (e.g., "7d", "1h", "30m") if strings.HasSuffix(timeStr, "d") { days := strings.TrimSuffix(timeStr, "d") if daysInt, err := strconv.Atoi(days); err == nil { return nostr.Now() - nostr.Timestamp(daysInt*24*60*60), nil } } else if strings.HasSuffix(timeStr, "h") { hours := strings.TrimSuffix(timeStr, "h") if hoursInt, err := strconv.Atoi(hours); err == nil { return nostr.Now() - nostr.Timestamp(hoursInt*60*60), nil } } else if strings.HasSuffix(timeStr, "m") { minutes := strings.TrimSuffix(timeStr, "m") if minutesInt, err := strconv.Atoi(minutes); err == nil { return nostr.Now() - nostr.Timestamp(minutesInt*60), nil } } // try to parse as direct timestamp if ts, err := strconv.ParseInt(timeStr, 10, 64); err == nil { return nostr.Timestamp(ts), nil } return 0, fmt.Errorf("invalid time format: %s", timeStr) } type SpellHistoryEntry struct { Identifier string `json:"_id"` Name string `json:"name,omitempty"` Content string `json:"content,omitempty"` LastUsed time.Time `json:"last_used"` Pointer nostr.EventPointer `json:"pointer"` } func getSpellHistoryPath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } historyDir := filepath.Join(home, ".config", "nak", "spells") // create directory if it doesn't exist if err := os.MkdirAll(historyDir, 0755); err != nil { return "", err } return filepath.Join(historyDir, "history"), nil }