discodoc/doc.go

570 lines
14 KiB
Go

package main
import (
"context"
"fmt"
"log"
"path"
"strings"
"sync"
"time"
"github.com/diamondburned/arikawa/v3/api"
"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/gateway"
"github.com/hhhapz/doc"
"github.com/pkg/errors"
)
var (
searchErr = "Could not find package with the name of `%s`."
notFound = "Could not find type or function `%s` in package `%s`"
methodNotFound = "Could not find method `%s` for type `%s` in package `%s`"
notOwner = "Only the message sender can do this."
cannotExpand = "You cannot expand this embed."
privilegedRoles = map[discord.RoleID]struct{}{
// Gopher Herder ID on discord.gg/golang.
370280974593818644: {},
}
)
type interactionData struct {
id string
created time.Time
token string
userID discord.UserID
query string
full bool
}
var (
interactionMap = map[string]*interactionData{}
mu sync.Mutex
)
func (b *botState) gcInteractionData() {
mapTicker := time.NewTicker(time.Minute * 5)
cacheTicker := time.NewTicker(time.Hour * 24)
for {
select {
// gc interaction tokens
case <-mapTicker.C:
now := time.Now()
mu.Lock()
for _, data := range interactionMap {
if !now.After(data.created.Add(time.Minute * 5)) {
continue
}
delete(interactionMap, data.id)
b.state.EditInteractionResponse(b.appID, data.token, api.EditInteractionResponseData{
Components: &[]discord.Component{},
})
}
mu.Unlock()
case <-cacheTicker.C:
b.searcher.WithCache(func(cache map[string]*doc.CachedPackage) {
for k := range cache {
delete(cache, k)
}
})
}
}
}
func (b *botState) handleDocs(e *gateway.InteractionCreateEvent) {
data := api.InteractionResponse{Type: api.DeferredMessageInteractionWithSource}
if err := b.state.RespondInteraction(e.ID, e.Token, data); err != nil {
log.Println(errors.Wrap(err, "could not send interaction callback"))
return
}
args := map[string]gateway.InteractionOption{}
for _, arg := range e.Data.Options {
args[arg.Name] = arg
}
query := args["query"].String()
embed := b.onDocs(e, query, false)
if strings.HasPrefix(embed.Title, "Error") {
err := b.state.DeleteInteractionResponse(e.AppID, e.Token)
if err != nil {
log.Println("failed to delete message:", err)
return
}
_, _ = b.state.CreateInteractionFollowup(e.AppID, e.Token, api.InteractionResponseData{
Flags: api.EphemeralResponse,
Embeds: &[]discord.Embed{embed},
})
return
}
mu.Lock()
interactionMap[e.ID.String()] = &interactionData{
id: e.ID.String(),
created: time.Now(),
token: e.Token,
userID: e.User.ID,
query: query,
}
mu.Unlock()
if _, err := b.state.EditInteractionResponse(e.AppID, e.Token, api.EditInteractionResponseData{
Embeds: &[]discord.Embed{embed},
Components: &[]discord.Component{
discord.ActionRowComponent{
Components: []discord.Component{
discord.SelectComponent{
CustomID: e.ID.String(),
Options: selectOptions(false),
Placeholder: "Actions",
},
},
},
},
}); err != nil {
log.Println(errors.Wrap(err, "could not send interaction callback"))
return
}
}
func (b *botState) onDocsComponent(e *gateway.InteractionCreateEvent, data *interactionData) {
var embed discord.Embed
var components *[]discord.Component
// if e.Member is nil, all operations should be allowed
hasRole := e.Member == nil
if !hasRole {
for _, role := range e.Member.RoleIDs {
if _, ok := privilegedRoles[role]; ok {
hasRole = true
break
}
}
}
isAdmin := func() bool {
if e.Member == nil {
return true
}
perms, err := b.state.Permissions(e.ChannelID, e.User.ID)
if err != nil {
return false
}
if !perms.Has(discord.PermissionAdministrator) {
return false
}
return true
}
action := e.Data.Values[0]
switch action {
case "minimize":
embed, data.full = b.onDocs(e, data.query, false), false
components = &[]discord.Component{
discord.ActionRowComponent{
Components: []discord.Component{
discord.SelectComponent{
CustomID: data.id,
Options: selectOptions(false),
Placeholder: "Actions",
},
},
},
}
// Admin + privileged only.
// (Only check admin here to reduce total API calls).
// If not privileged, send ephemeral instead.
case "expand":
embed, data.full = b.onDocs(e, data.query, true), true
components = &[]discord.Component{
discord.ActionRowComponent{
Components: []discord.Component{
discord.SelectComponent{
CustomID: data.id,
Options: selectOptions(true),
Placeholder: "Actions",
},
},
},
}
if !isAdmin() {
_ = b.state.RespondInteraction(e.ID, e.Token, api.InteractionResponse{
Type: api.MessageInteractionWithSource,
Data: &api.InteractionResponseData{
Flags: api.EphemeralResponse,
Embeds: &[]discord.Embed{embed},
},
})
break
}
case "hide":
components = &[]discord.Component{}
embed = b.onDocs(e, data.query, data.full)
embed.Description = ""
embed.Footer = nil
mu.Lock()
delete(interactionMap, data.id)
mu.Unlock()
}
if e.GuildID != discord.NullGuildID {
// Check admin last.
if e.User.ID != data.userID && !hasRole && !isAdmin() {
embed = failEmbed("Error", notOwner)
}
}
var resp api.InteractionResponse
if strings.HasPrefix(embed.Title, "Error") {
resp = api.InteractionResponse{
Type: api.MessageInteractionWithSource,
Data: &api.InteractionResponseData{
Flags: api.EphemeralResponse,
Embeds: &[]discord.Embed{embed},
},
}
} else {
resp = api.InteractionResponse{
Type: api.UpdateMessage,
Data: &api.InteractionResponseData{
Embeds: &[]discord.Embed{embed},
Components: components,
},
}
}
b.state.RespondInteraction(e.ID, e.Token, resp)
}
func (b *botState) onDocs(e *gateway.InteractionCreateEvent, query string, full bool) discord.Embed {
module, parts := parseQuery(query)
pkg, err := b.searcher.Search(context.Background(), module)
if err != nil {
log.Printf("Package request by %s failed: %v", e.User.Tag(), err)
return failEmbed("Error", fmt.Sprintf(searchErr, module))
}
switch len(parts) {
case 0:
return b.fullPackage(pkg, full)
case 1:
if typ, ok := pkg.Types[parts[0]]; ok {
return b.typ(pkg, typ, full)
}
if fn, ok := pkg.Functions[parts[0]]; ok {
return b.fn(pkg, fn, full)
}
return failEmbed("Error: Not Found", fmt.Sprintf(notFound, parts[0], module))
default:
typ, ok := pkg.Types[parts[0]]
if !ok {
return failEmbed("Error: Not Found", fmt.Sprintf(notFound, parts[0], module))
}
method, ok := typ.Methods[parts[1]]
if !ok {
return failEmbed("Error: Not Found", fmt.Sprintf(methodNotFound, parts[1], parts[0], module))
}
return b.method(pkg, method, full)
}
}
const (
docLimit = 2800
defLimit = 1000
accentColor = 0x007D9C
)
func (b *botState) fullPackage(pkg doc.Package, full bool) discord.Embed {
return discord.Embed{
Title: "Package " + pkg.URL,
URL: "https://pkg.go.dev/" + pkg.URL,
Description: fmt.Sprintf("**Types:** %d\n**Functions:** %d\n\n%s",
len(pkg.Types), len(pkg.Functions), format(pkg.Overview, 32, full)),
Color: accentColor,
Footer: &discord.EmbedFooter{
Text: "https://pkg.go.dev/" + pkg.URL,
},
}
}
func (b *botState) typ(pkg doc.Package, typ doc.Type, full bool) discord.Embed {
def := typdef(typ.Signature, full)
return discord.Embed{
Title: fmt.Sprintf("%s: %s", pkg.URL, typ.Name),
URL: fmt.Sprintf("https://pkg.go.dev/%s#%s", pkg.URL, typ.Name),
Description: fmt.Sprintf("```go\n%s\n```\n%s", def, format(typ.Comment, len(def), full)),
Color: accentColor,
Footer: &discord.EmbedFooter{
Text: "https://pkg.go.dev/" + pkg.URL,
},
}
}
func (b *botState) fn(pkg doc.Package, fn doc.Function, full bool) discord.Embed {
def := typdef(fn.Signature, full)
return discord.Embed{
Title: fmt.Sprintf("%s: %s", pkg.URL, fn.Name),
URL: fmt.Sprintf("https://pkg.go.dev/%s#%s", pkg.URL, fn.Name),
Description: fmt.Sprintf("```go\n%s\n```\n%s", def, format(fn.Comment, len(def), full)),
Color: accentColor,
Footer: &discord.EmbedFooter{
Text: "https://pkg.go.dev/" + pkg.URL,
},
}
}
func (b *botState) method(pkg doc.Package, method doc.Method, full bool) discord.Embed {
def := typdef(method.Signature, full)
return discord.Embed{
Title: fmt.Sprintf("%s: %s.%s", pkg.URL, method.For, method.Name),
URL: fmt.Sprintf("https://pkg.go.dev/%s#%s.%s", pkg.URL, method.For, method.Name),
Description: fmt.Sprintf("```go\n%s\n```\n%s", def, format(method.Comment, len(def), full)),
Color: accentColor,
Footer: &discord.EmbedFooter{
Text: "https://pkg.go.dev/" + pkg.URL,
},
}
}
func selectOptions(full bool) []discord.SelectComponentOption {
expand := discord.SelectComponentOption{
Label: "Expand",
Value: "expand",
Description: "Show more documentation.",
Emoji: &discord.ButtonEmoji{Name: "⬇️"},
}
if full {
expand = discord.SelectComponentOption{
Label: "Minimize",
Value: "minimize",
Description: "Show less documentation.",
Emoji: &discord.ButtonEmoji{Name: "⬆️"},
}
}
return []discord.SelectComponentOption{
expand,
{
Label: "Hide",
Value: "hide",
Description: "Hide the message.",
Emoji: &discord.ButtonEmoji{Name: "❌"},
},
}
}
func failEmbed(title, description string) discord.Embed {
return discord.Embed{
Title: title,
Description: description,
Color: 0xEE0000,
}
}
func parseQuery(module string) (string, []string) {
module = strings.ReplaceAll(module, " ", ".")
dir, base := path.Split(strings.ToLower(module))
split := strings.Split(base, ".")
full := dir + split[0]
if strings.HasPrefix(full, "x/") {
full = "golang.org/" + full
}
if complete, ok := stdlibPackages[full]; ok {
full = complete
}
return full, split[1:]
}
func typdef(def string, full bool) string {
split := strings.Split(def, "\n")
if !full {
return split[0]
}
b := strings.Builder{}
b.Grow(len(def))
for _, line := range strings.Split(def, "\n") {
b.WriteRune('\n')
if len(line)+b.Len() > defLimit {
b.WriteString("// full signature omitted")
break
}
b.WriteString(line)
}
return b.String()
}
func format(c doc.Comment, initial int, full bool) string {
if len(c) == 0 {
return "*No documentation found*"
}
if !full {
md := c[0].Markdown()
if len(md) > 500 {
md = md[:400] + "...\n\n*More documentation omitted*"
}
if len(c) == 1 {
return md
}
return fmt.Sprintf("%s\n\n*More documentation omitted*", md)
}
var parts doc.Comment
length := initial
for _, note := range c {
l := len(note.Text())
if l+length > docLimit {
parts = append(parts, doc.Paragraph("*More documentation omitted...*"))
break
}
length += l
parts = append(parts, note)
}
return parts.Markdown()
}
var stdlibPackages = map[string]string{
"tar": "archive/tar",
"zip": "archive/zip",
"bzip2": "compress/bzip2",
"flate": "compress/flate",
"gzip": "compress/gzip",
"lzw": "compress/lzw",
"zlib": "compress/zlib",
"heap": "container/heap",
"list": "container/list",
"ring": "container/ring",
"aes": "crypto/aes",
"cipher": "crypto/cipher",
"des": "crypto/des",
"dsa": "crypto/dsa",
"ecdsa": "crypto/ecdsa",
"ed25519": "crypto/ed25519",
"elliptic": "crypto/elliptic",
"hmac": "crypto/hmac",
"md5": "crypto/md5",
"rc4": "crypto/rc4",
"rsa": "crypto/rsa",
"sha1": "crypto/sha1",
"sha256": "crypto/sha256",
"sha512": "crypto/sha512",
"subtle": "crypto/subtle",
"tls": "crypto/tls",
"x509": "crypto/x509",
"pkix": "crypto/x509/pkix",
"dwarf": "debug/dwarf",
"elf": "debug/elf",
"gosym": "debug/gosym",
"macho": "debug/macho",
"pe": "debug/pe",
"plan9obj": "debug/plan9obj",
"ascii85": "encoding/ascii85",
"asn1": "encoding/asn1",
"base32": "encoding/base32",
"base64": "encoding/base64",
"binary": "encoding/binary",
"csv": "encoding/csv",
"gob": "encoding/gob",
"hex": "encoding/hex",
"json": "encoding/json",
"pem": "encoding/pem",
"xml": "encoding/xml",
"ast": "go/ast",
"build": "go/build",
"constraint": "go/build/constraint",
"constant": "go/constant",
"docformat": "go/docformat",
"importer": "go/importer",
"parserprinter": "go/parserprinter",
"scanner": "go/scanner",
"token": "go/token",
"types": "go/types",
"adler32": "hash/adler32",
"crc32": "hash/crc32",
"crc64": "hash/crc64",
"fnv": "hash/fnv",
"maphash": "hash/maphash",
"color": "image/color",
"draw": "image/draw",
"gif": "image/gif",
"jpeg": "image/jpeg",
"parsing": "image/parsing",
"suffixarray": "index/suffixarray",
"fs": "io/fs",
"ioutil": "io/ioutil",
"big": "math/big",
"bits": "math/bits",
"cmplx": "math/cmplx",
"multipart": "mime/multipart",
"quotedprintable": "mime/quotedprintable",
"http": "net/http",
"cgi": "net/http/cgi",
"cookiejar": "net/http/cookiejar",
"fcgi": "net/http/fcgi",
"httptest": "net/http/httptest",
"httptrace": "net/http/httptrace",
"httputil": "net/http/httputil",
"mail": "net/mail",
"rpc": "net/rpc",
"jsonrpc": "net/rpc/jsonrpc",
"smtp": "net/smtp",
"textproto": "net/textproto",
"exec": "os/exec",
"signal": "os/signal",
"user": "os/user",
"filepath": "path/filepath",
"syntax": "regexp/syntax",
"cgo": "runtime/cgo",
"metrics": "runtime/metrics",
"msan": "runtime/msan",
"race": "runtime/race",
"trace": "runtime/trace",
"js": "syscall/js",
"fstest": "testing/fstest",
"iotest": "testing/iotest",
"quick": "testing/quick",
"tabwriter": "text/tabwriter",
"parse": "text/template/parse",
"tzdata": "time/tzdata",
"utf16": "unicode/utf16",
"utf8": "unicode/utf8",
}