diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..72eade3 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +worker: hyperdocs \ No newline at end of file diff --git a/cmd/hyperdocs/main.go b/cmd/hyperdocs/main.go index 61d2895..ef8beba 100644 --- a/cmd/hyperdocs/main.go +++ b/cmd/hyperdocs/main.go @@ -1,15 +1,18 @@ package main import ( + "errors" "fmt" - "log" "os" "os/signal" "syscall" "github.com/bwmarrin/discordgo" + "github.com/go-redis/redis/v8" "github.com/joho/godotenv" + log "github.com/sirupsen/logrus" + "hyperdocs/config" "hyperdocs/internal/sources" discordgoutil "hyperdocs/pkg/discordgo" ) @@ -28,11 +31,9 @@ var ( var discordDocTmpl = `https://discord.dev/{{ .topic.Value }}/{{ .page.Value }}{{ with $x := (index . "paragraph-id").Value }}#{{ $x }}{{ end }}` -func registerCommands(session *discordgo.Session, commands []*discordgo.ApplicationCommand) { - guild := os.Getenv("DISCORD_GUILD") - app := os.Getenv("DISCORD_ID") +func registerCommands(cfg config.Config, session *discordgo.Session, commands []*discordgo.ApplicationCommand) { for _, cmd := range commands { - _, err := session.ApplicationCommandCreate(app, guild, cmd) + _, err := session.ApplicationCommandCreate(cfg.AppID, cfg.TestingGuild, cmd) if err != nil { log.Fatal(fmt.Errorf("cannot register %q command: %w", cmd.Name, err)) } @@ -41,9 +42,11 @@ func registerCommands(session *discordgo.Session, commands []*discordgo.Applicat func init() { err := godotenv.Load() - if err != nil { + if err != nil && !errors.Is(err, os.ErrNotExist) { log.Fatal(fmt.Errorf("cannot load env file: %w", err)) } + + log.SetReportCaller(true) } func awaitForInterrupt() { @@ -54,20 +57,45 @@ func awaitForInterrupt() { func main() { fmt.Println(banner) - session, err := discordgo.New("Bot " + os.Getenv("DISCORD_TOKEN")) + cfg, err := config.Load() + if err != nil { + log.Fatal(fmt.Errorf("cannot load configuration: %w", err)) + } + session, err := discordgo.New("Bot " + cfg.Token) + if err != nil { + log.Fatal(fmt.Errorf("cannot construct session: %w", err)) + } + redisOpts, err := redis.ParseURL(cfg.Redis) if err != nil { - panic(fmt.Errorf("cannot construct session: %w", err)) + log.Fatal(fmt.Errorf("cannot parse redis url: %w", err)) } - registerCommands(session, []*discordgo.ApplicationCommand{ + sourcesList := sources.Sources(cfg, redis.NewClient(redisOpts)) + + registerCommands(cfg, session, []*discordgo.ApplicationCommand{ { Name: "docs", Description: "Open sesame the documentation vault.", - Options: optionsFromSourceList(sources.Sources), + Options: optionsFromSourceList(sourcesList), + }, + { + Name: "invite", + Description: "Invite the bot", }, }) - session.AddHandler(discordgoutil.NewCommandHandler(makeHandlersMap(sources.Sources))) + session.AddHandler(discordgoutil.NewCommandHandler(makeHandlersMap(sourcesList))) + session.AddHandler(discordgoutil.NewCommandHandler(map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ + "invite": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Flags: 1 << 6, + Content: fmt.Sprintf(`To invite me - click [here](https://discord.com/api/oauth2/authorize?client_id=%s&permissions=378944&scope=bot+applications.commands)`, s.State.User.ID), + }, + }) + }, + })) session.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { log.Println("Bot is up!") }) diff --git a/cmd/hyperdocs/sources.go b/cmd/hyperdocs/sources.go index 16240bd..f113468 100644 --- a/cmd/hyperdocs/sources.go +++ b/cmd/hyperdocs/sources.go @@ -45,7 +45,10 @@ func makeInteractionHandler(src sources.Source) func(s *discordgo.Session, i *di fmt.Println(err) } return + } else if err != nil { + fmt.Println(err) } + desc, fields := symbol.Render() err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..94d88b4 --- /dev/null +++ b/config/config.go @@ -0,0 +1,20 @@ +package config + +import ( + "time" +) + +type SourcesConfig struct { + Discord struct { + // TTL of discord docs sources (in seconds) + RedisTTL time.Duration `envconfig:"ttl" default:"1m"` + } +} + +type Config struct { + Token string `required:"true"` + AppID string `envconfig:"app_id" required:"true"` + TestingGuild string + Redis string `envconfig:"redis_url" required:"true"` + Sources SourcesConfig +} diff --git a/config/loader.go b/config/loader.go new file mode 100644 index 0000000..d735ec7 --- /dev/null +++ b/config/loader.go @@ -0,0 +1,20 @@ +package config + +import ( + "os" + "strings" + + "github.com/kelseyhightower/envconfig" +) + +func Load() (cfg Config, err error) { + var prefix string = "hyperdocs" + switch strings.ToLower(os.Getenv("HYPERDOCS_ENV")) { + case "production": + prefix = "" + case "testing": + prefix += "_test" + } + err = envconfig.Process(prefix, &cfg) + return +} diff --git a/go.mod b/go.mod index 7ec976b..693b3a8 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,18 @@ module hyperdocs +// +heroku goVersion go1.15 go 1.15 require ( github.com/PuerkitoBio/goquery v1.7.1 - github.com/bwmarrin/discordgo v0.23.3-0.20210821175000-0fad116c6c2a + github.com/bwmarrin/discordgo v0.25.0 + github.com/go-redis/redis v6.15.9+incompatible + github.com/go-redis/redis/v8 v8.11.4 github.com/gomarkdown/markdown v0.0.0-20210820032736-385812cbea76 + github.com/gorilla/websocket v1.5.0 // indirect github.com/joho/godotenv v1.3.0 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/sirupsen/logrus v1.9.0 // indirect + golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect + golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect ) diff --git a/go.sum b/go.sum index fe7842f..327b78b 100644 --- a/go.sum +++ b/go.sum @@ -4,23 +4,125 @@ github.com/andybalholm/cascadia v1.2.0 h1:vuRCkM5Ozh/BfmsaTm26kbjm0mIOM3yS5Ek/F5 github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY= github.com/bwmarrin/discordgo v0.23.3-0.20210821175000-0fad116c6c2a h1:L7EuIzka83l5Z7LQqpSBfvmTNvUdr9tGhBa0mDBgSsc= github.com/bwmarrin/discordgo v0.23.3-0.20210821175000-0fad116c6c2a/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/bwmarrin/discordgo v0.25.0 h1:NXhdfHRNxtwso6FPdzW2i3uBvvU7UIQTghmV2T4nqAs= +github.com/bwmarrin/discordgo v0.25.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= +github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= +github.com/go-redis/redis/v8 v8.11.4 h1:kHoYkfZP6+pe04aFTnhDH6GDROa5yJdHJVNxV3F46Tg= +github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/gomarkdown/markdown v0.0.0-20210820032736-385812cbea76 h1:nrGXmvqQjFCxD27hosXjoVaDtPL1tVvJ6iGXucNXCVE= github.com/gomarkdown/markdown v0.0.0-20210820032736-385812cbea76/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/sources/discord.go b/internal/sources/discord.go index 43d5a3d..7809298 100644 --- a/internal/sources/discord.go +++ b/internal/sources/discord.go @@ -2,24 +2,32 @@ package sources import ( "context" + "encoding/json" "fmt" "io/ioutil" "net/http" "path" + "regexp" "strings" + "time" "github.com/bwmarrin/discordgo" + "github.com/go-redis/redis/v8" "github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown/ast" "github.com/gomarkdown/markdown/parser" + log "github.com/sirupsen/logrus" + "hyperdocs/config" discordgoutil "hyperdocs/pkg/discordgo" mdutil "hyperdocs/pkg/markdown" ) var ( - discordDocsRepoURL = "https://raw.githubusercontent.com/discord/discord-api-docs" - discordDocsBranch = "master" + discordDocsRepo = "discord/discord-api-docs" + discordDocsRepoURL = "https://raw.githubusercontent.com/discord/discord-api-docs" + // discordDocsBranch = "master" + discordDocsBranch = "main" discordDocsFilesURL = discordDocsRepoURL + "/" + discordDocsBranch + "/docs" discordDocsFileURL = func(filepath string) string { return discordDocsFilesURL + "/" + filepath } @@ -27,9 +35,14 @@ var ( discordDocsHeadingURL = func(topic, page, heading string) string { return discordDocsURL + "/" + topic + "/" + page + "#" + heading } + + discordDocsCacheKey = "discord.sources" ) -type Discord struct{} +type Discord struct { + Config config.Config + Cache *redis.Client +} // Name of the resource. It is being used as the source command name func (d Discord) Name() string { @@ -71,6 +84,8 @@ func (d Discord) Process(ctx context.Context, s *discordgo.Session, i *discordgo type Visitor struct { LiteralTarget string + LiteralPrefix string + LiteralSuffix string ResultNode ast.Node } @@ -109,15 +124,17 @@ func printNode(node ast.Node, nesting int) { } func (f *Visitor) Visit(node ast.Node, entering bool) ast.WalkStatus { - var literal []byte + var literal string if leaf := node.AsLeaf(); leaf != nil { - literal = leaf.Literal + literal = string(leaf.Literal) } else if container := node.AsContainer(); container != nil { - literal = container.Literal + literal = string(container.Literal) } - if string(literal) == f.LiteralTarget { + if (f.LiteralTarget != "" && literal == f.LiteralTarget) || + (f.LiteralPrefix != "" && strings.HasPrefix(literal, f.LiteralPrefix)) || + (f.LiteralSuffix != "" && strings.HasSuffix(literal, f.LiteralSuffix)) { f.ResultNode = node return ast.SkipChildren } @@ -125,10 +142,108 @@ func (f *Visitor) Visit(node ast.Node, entering bool) ast.WalkStatus { return ast.GoToNext } -func formatDiscordUrlParameter(param string) string { +var referenceLinkRegex = regexp.MustCompile(`(?:([\w.]+)(#[\w\-\/:]+))`) + +func (discord *Discord) resolveDiscordMarkdownReferences(ref string) string { + if strings.HasPrefix(ref, "#DOCS") { + // TODO: fallback situation (hitting ratelimits when cache provider is down) + if v, err := discord.Cache.Exists(context.TODO(), discordDocsCacheKey).Result(); err != nil || v == 0 { + resp, err := http.Get("https://api.github.com/repos/" + discordDocsRepo + "/git/trees/" + discordDocsBranch + "?recursive=1") + if err != nil { // TODO: proper error handling + log.Println(err) + return "https://discord.com/developers/docs/intro" + } + var payload struct { + Tree []struct { + Path string `json:"path"` + } `json:"tree"` + } + err = json.NewDecoder(resp.Body).Decode(&payload) + if err != nil { // TODO: proper error handling + log.Println(err) + return "https://discord.com/developers/docs/intro" + } + + for _, item := range payload.Tree { + item.Path = strings.TrimSuffix(item.Path, ".md") + res := discord.Cache.HSet( + context.TODO(), + discordDocsCacheKey, + strings.ReplaceAll(strings.ToUpper(item.Path), "/", "_"), + item.Path, + ) + if res.Err() != nil { + log.Println(fmt.Errorf("cannot cache %s: %w", item, res.Err())) + } + } + + discord.Cache.Expire( + context.TODO(), + discordDocsCacheKey, + time.Duration(time.Duration(discord.Config.Sources.Discord.RedisTTL)*time.Second), + ) + } else if err != nil { + log.Println(err) + return "https://discord.com/developers/docs/intro" + } + + ref = strings.TrimPrefix(ref, "#") + pathSegments := strings.Split(ref, "/") + path, err := discord.Cache.HGet(context.TODO(), discordDocsCacheKey, pathSegments[0]).Result() + if err != nil { // TODO: proper error handling + log.Println(err) + return "https://discord.com/developers/docs/intro" + } + return strings.ToLower("https://discord.com/developers/" + path + "#" + pathSegments[1]) + // pathSegments[2] = strings.SplitAfter strings.ReplaceAll(strings.Title(strings.ToLower(pathSegments[2]), "And", "and"), + } + return ref +} + +func (discord *Discord) encodeMDReferenceLinks(src string) string { + // split := strings.SplitN(referenceLinkRegex.FindStringSubmatch(src)[2], "_", 2)[1:] + // split[0] + res := referenceLinkRegex.ReplaceAllStringFunc(src, func(s string) string { + submatches := referenceLinkRegex.FindStringSubmatch(s) + + return fmt.Sprintf("[%s](%s)", submatches[1], discord.resolveDiscordMarkdownReferences(submatches[2])) + }) + // res := referenceLinkRegex.ReplaceAllString(src, "[${1}](${2})") + return res +} + +func normalizeDiscordUrlParameter(param string) string { return strings.ReplaceAll(strings.ToLower(param), " ", "-") } +func parseEndpointParams(table *ast.Table) (params []APIEndpointParameter) { + header := []string{} + for _, v := range ast.GetFirstChild(ast.GetFirstChild(table)).GetChildren() { + header = append(header, string(ast.GetFirstChild(v).AsLeaf().Literal)) + } + rows := table.GetChildren()[1].GetChildren() + for _, row := range rows { + param := APIEndpointParameter{ + Additional: make(map[string]string), + } + children := row.GetChildren() + for idx, col := range header { + switch strings.ToLower(col) { + case "field": + param.Name = mdutil.RenderTextNode(children[idx]) + case "description": + param.Description = mdutil.RenderTextNode(children[idx]) + case "type": + param.Type = mdutil.RenderTextNode(children[idx]) + default: + param.Additional[col] = mdutil.RenderTextNode(children[idx]) + } + } + params = append(params, param) + } + return +} + // Search processes the input and returns the symbol by specified parameters. func (d Discord) Search(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) (Symbol, error) { data := i.ApplicationCommandData() @@ -160,9 +275,9 @@ func (d Discord) Search(ctx context.Context, s *discordgo.Session, i *discordgo. } parsed := markdown.Parse(body, parser.NewWithExtensions(parser.CommonExtensions|parser.AutoHeadingIDs)) - visitor := &Visitor{LiteralTarget: options["paragraph-name"].StringValue()} - ast.Walk(parsed, visitor) + visitor := &Visitor{LiteralPrefix: options["paragraph-name"].StringValue()} + ast.Walk(parsed, visitor) top := visitor.ResultNode if top == nil { @@ -173,6 +288,63 @@ func (d Discord) Search(ctx context.Context, s *discordgo.Session, i *discordgo. top = top.GetParent() } + if split := strings.Split(string(visitor.ResultNode.AsLeaf().Literal), "%"); len(split) > 1 { + split[0] = strings.TrimSpace(split[0]) + split[1] = strings.TrimSpace(split[1]) + endpointSignature := strings.Split(split[1], " ") + var params, query []APIEndpointParameter + _ = query + var rendered string + current := ast.GetNextNode(visitor.ResultNode.GetParent()) + stopCollectingAPIMethod: + for current != nil { + var result string + switch v := current.(type) { + case *ast.BlockQuote: + result = mdutil.RenderHintNode(v, mdutil.HintKindMapping{ + "info": ":information_source:", + "warn": ":warning:", + "danger": ":octagonal_sign:", + }) + case *ast.Heading: + if v.Level <= 6 { + switch strings.ToLower(string(ast.GetFirstChild(current).AsLeaf().Literal)) { + case "json params": + current = ast.GetNextNode(current) + table, ok := current.(*ast.Table) + if !ok { + continue stopCollectingAPIMethod + } + params = parseEndpointParams(table) + continue stopCollectingAPIMethod + } + } else { + break stopCollectingAPIMethod + } + default: + result = mdutil.RenderStringNode(current) + if result == "" { + break stopCollectingAPIMethod + } + } + rendered += result + "\n\n" + current = ast.GetNextNode(current) + } + // fmt.Println(rendered) + return APIEndpoint{ + Name: strings.TrimSpace(split[0]), + Description: rendered, + Method: strings.ToUpper(endpointSignature[0]), + Endpoint: d.encodeMDReferenceLinks(endpointSignature[1]), + Parameters: params, + Link: discordDocsHeadingURL( + normalizeDiscordUrlParameter(options["topic"].StringValue()), + normalizeDiscordUrlParameter(options["page"].StringValue()), + normalizeDiscordUrlParameter(strings.ToLower(strings.ReplaceAll(split[0], " ", "-"))), + ), + }, nil + } + current := ast.GetNextNode(top) var rendered string @@ -200,9 +372,16 @@ stopCollecting: Content: rendered, Title: string(ast.GetFirstChild(visitor.ResultNode.GetParent()).AsLeaf().Literal), Source: discordDocsHeadingURL( - formatDiscordUrlParameter(options["topic"].StringValue()), - formatDiscordUrlParameter(options["page"].StringValue()), - formatDiscordUrlParameter((visitor.ResultNode.GetParent().(*ast.Heading)).HeadingID), + normalizeDiscordUrlParameter(options["topic"].StringValue()), + normalizeDiscordUrlParameter(options["page"].StringValue()), + normalizeDiscordUrlParameter((visitor.ResultNode.GetParent().(*ast.Heading)).HeadingID), ), }, nil } + +func NewDiscord(cfg config.Config, redisClient *redis.Client) Source { + return &Discord{ + Config: cfg, + Cache: redisClient, + } +} diff --git a/internal/sources/source.go b/internal/sources/source.go index a61261b..3d06f4b 100644 --- a/internal/sources/source.go +++ b/internal/sources/source.go @@ -2,8 +2,10 @@ package sources import ( "context" + "hyperdocs/config" "github.com/bwmarrin/discordgo" + "github.com/go-redis/redis/v8" ) // Source is the representation of a single documentation source. @@ -21,7 +23,15 @@ type Source interface { Search(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) (Symbol, error) } -// SourcesList is a map of supported all documentation sources -var Sources = []Source{ - Discord{}, +// Sources is a list of constructors for of all supported documentation sources +var SourcesRaw = []func(config.Config, *redis.Client) Source{ + NewDiscord, +} + +// Sources returns a map of all supported documentation sources +func Sources(cfg config.Config, redisClient *redis.Client) (r []Source) { + for _, s := range SourcesRaw { + r = append(r, s(cfg, redisClient)) + } + return } diff --git a/internal/sources/symbols.go b/internal/sources/symbols.go index 3674b73..835d2d7 100644 --- a/internal/sources/symbols.go +++ b/internal/sources/symbols.go @@ -1,6 +1,8 @@ package sources import ( + "fmt" + "github.com/bwmarrin/discordgo" ) @@ -14,8 +16,8 @@ const ( SymbolInterface // SymbolFunction indicates that the symbol is a function/method. SymbolFunction - // SymbolAPIMethod indicates that the symbol is API endpoint. - SymbolAPIMethod + // SymbolAPIEndpoint indicates that the symbol is API endpoint. + SymbolAPIEndpoint // SymbolParagraph indicates that the symbol is a simple text paragraph (in markdown for example). SymbolParagraph ) @@ -46,7 +48,54 @@ type Function struct { Childs []Symbol } -type APIMethod struct{} +type APIEndpointParameter struct { + Name string + Type string + Description string + Additional map[string]string +} + +type APIEndpoint struct { + Name string + Link string + Method string + Endpoint string + Parameters []APIEndpointParameter + QueryParameters []APIEndpointParameter + Description string +} + +func (e APIEndpoint) GetName() string { + return e.Name +} + +func (e APIEndpoint) GetLink() string { + return e.Link +} + +func (e APIEndpoint) Type() SymbolType { + return SymbolAPIEndpoint +} + +func (e APIEndpoint) Render() (desc string, fields []*discordgo.MessageEmbedField) { + params := "" + for _, param := range e.Parameters { + additional := "" + for name, value := range param.Additional { + additional += fmt.Sprintf("**%s**: %s\n", name, value) + } + params += fmt.Sprintf("**`%s`** - %s\n**Type**: %s\n%s\n", param.Name, param.Description, param.Type, additional) + } + desc = fmt.Sprintf("**%s** %q\n\n%s", e.Method, e.Endpoint, e.Description) + if params != "" { + // fields = append(fields, &discordgo.MessageEmbedField{ + // Name: "JSON Params", + // Value: params, + // }) + desc += "**__JSON Params__**\n\n" + params + } + return desc, fields +} type Paragraph struct { Title string diff --git a/pkg/markdown/render.go b/pkg/markdown/render.go index 499a578..5edb4a5 100644 --- a/pkg/markdown/render.go +++ b/pkg/markdown/render.go @@ -27,8 +27,8 @@ func RenderCodeBlockNode(node *ast.CodeBlock) string { return RenderCodeBlock(string(node.Info), string(node.Literal)) } -// RenderParagraphNode renders *ast.Paragraph node into a string. -func RenderParagraphNode(node *ast.Paragraph) (result string) { +// RenderTextNode renders a text node into a string. +func RenderTextNode(node ast.Node) (result string) { if node == nil { return "" } @@ -69,7 +69,7 @@ func RenderBlockQuote(content string, multiline bool) string { func RenderBlockQuoteNode(node *ast.BlockQuote) (res string) { switch v := ast.GetFirstChild(node).(type) { case *ast.Paragraph: - res = RenderParagraphNode(v) + res = RenderTextNode(v) case *ast.CodeBlock: res = RenderCodeBlockNode(v) } @@ -109,11 +109,15 @@ func RenderHintNode(node *ast.BlockQuote, kindMappings HintKindMapping) (res str func RenderStringNode(node ast.Node) string { switch v := node.(type) { case *ast.Paragraph: - return RenderParagraphNode(v) + return RenderTextNode(v) case *ast.CodeBlock: return RenderCodeBlockNode(v) case *ast.BlockQuote: - return RenderBlockQuoteNode(v) + return RenderHintNode(v, HintKindMapping{ + "info": ":information_source:", + "warn": ":warning:", + "danger": ":octagonal_sign:", + }) } return "" }