basic grimoire spell support.

This commit is contained in:
fiatjaf
2025-12-19 15:16:22 -03:00
parent 9bf728d850
commit 8f38468103
5 changed files with 522 additions and 107 deletions

398
spell.go Normal file
View File

@@ -0,0 +1,398 @@
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
}