diff options
| -rw-r--r-- | Earthfile | 10 | ||||
| -rw-r--r-- | cmd/aerial/.gitignore | 1 | ||||
| -rw-r--r-- | cmd/aerial/derpi/derpi.go | 87 | ||||
| -rw-r--r-- | cmd/aerial/embed.go | 236 | ||||
| -rw-r--r-- | cmd/aerial/hipster.go | 50 | ||||
| -rw-r--r-- | cmd/aerial/main.go | 88 | ||||
| -rw-r--r-- | cmd/aerial/pvfm.go | 260 | ||||
| -rw-r--r-- | internal/pvfm/bot/bot.go | 181 | ||||
| -rw-r--r-- | internal/pvfm/bot/doc.go | 10 | ||||
| -rw-r--r-- | internal/pvfm/bot/help.go | 38 | ||||
| -rw-r--r-- | internal/pvfm/commands/source/source.go | 8 | ||||
| -rw-r--r-- | internal/pvfm/fname.go | 28 | ||||
| -rw-r--r-- | internal/pvfm/info.go | 105 | ||||
| -rw-r--r-- | internal/pvfm/pvl/pvl.go | 77 | ||||
| -rw-r--r-- | internal/pvfm/recording/doc.go | 4 | ||||
| -rw-r--r-- | internal/pvfm/recording/recording.go | 120 | ||||
| -rw-r--r-- | internal/pvfm/recording/recording_demo.go | 59 | ||||
| -rw-r--r-- | internal/pvfm/schedule/schedule.go | 102 | ||||
| -rw-r--r-- | internal/pvfm/station/station.go | 96 | ||||
| -rw-r--r-- | kube/alrest/pvfm/aerial/1password.yaml | 8 | ||||
| -rw-r--r-- | kube/alrest/pvfm/aerial/deployment.yaml | 44 | ||||
| -rw-r--r-- | kube/alrest/pvfm/aerial/kustomization.yaml | 4 |
22 files changed, 1616 insertions, 0 deletions
@@ -42,6 +42,16 @@ azurda: SAVE IMAGE --push registry.fly.io/azurda:latest +aerial: + FROM +runtime + + COPY +everything/bin/aerial /app/bin/aerial + CMD ["/app/bin/aerial"] + + LABEL org.opencontainers.image.source="https://github.com/Xe/x" + + SAVE IMAGE --push ghcr.io/xe/x/aerial:latest + future-sight: FROM +runtime diff --git a/cmd/aerial/.gitignore b/cmd/aerial/.gitignore new file mode 100644 index 0000000..caed212 --- /dev/null +++ b/cmd/aerial/.gitignore @@ -0,0 +1 @@ +aerial diff --git a/cmd/aerial/derpi/derpi.go b/cmd/aerial/derpi/derpi.go new file mode 100644 index 0000000..eab910a --- /dev/null +++ b/cmd/aerial/derpi/derpi.go @@ -0,0 +1,87 @@ +package derpi + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" +) + +// DerpiResults is a struct to contain Derpibooru search results +type DerpiResults struct { + Search []struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DuplicateReports []interface{} `json:"duplicate_reports"` + FirstSeenAt time.Time `json:"first_seen_at"` + UploaderID string `json:"uploader_id"` + Score int `json:"score"` + CommentCount int `json:"comment_count"` + Width int `json:"width"` + Height int `json:"height"` + FileName string `json:"file_name"` + Description string `json:"description"` + Uploader string `json:"uploader"` + Image string `json:"image"` + Upvotes int `json:"upvotes"` + Downvotes int `json:"downvotes"` + Faves int `json:"faves"` + Tags string `json:"tags"` + TagIds []string `json:"tag_ids"` + AspectRatio float64 `json:"aspect_ratio"` + OriginalFormat string `json:"original_format"` + MimeType string `json:"mime_type"` + Sha512Hash string `json:"sha512_hash"` + OrigSha512Hash string `json:"orig_sha512_hash"` + SourceURL string `json:"source_url"` + Representations struct { + ThumbTiny string `json:"thumb_tiny"` + ThumbSmall string `json:"thumb_small"` + Thumb string `json:"thumb"` + Small string `json:"small"` + Medium string `json:"medium"` + Large string `json:"large"` + Tall string `json:"tall"` + Full string `json:"full"` + } `json:"representations"` + IsRendered bool `json:"is_rendered"` + IsOptimized bool `json:"is_optimized"` + } `json:"search"` + Total int `json:"total"` + Interactions []interface{} `json:"interactions"` +} + +// Perform a Derpibooru search query with a given string of tags and an API key +func SearchDerpi(tags string) (DerpiResults, error) { + + // format for URL query + tags += ",safe" // Enforce the safe tag for PG rating + derpiTags := strings.Replace(tags, " ", "+", -1) + + // make URL query + urlQuery := "https://derpibooru.org/search.json?q=" + derpiTags + resp, err := http.Get(urlQuery) + if err != nil { + return DerpiResults{}, fmt.Errorf("Failed with HTTP error.") + } + + // read response body + defer resp.Body.Close() + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return DerpiResults{}, fmt.Errorf("Failed with error reading response body.") + } + + // parse json + results := DerpiResults{} + err = json.Unmarshal(respBody, &results) + if err != nil { + return DerpiResults{}, fmt.Errorf("Failed with JSON parsing error.") + } + + return results, nil + +} diff --git a/cmd/aerial/embed.go b/cmd/aerial/embed.go new file mode 100644 index 0000000..b6f7d19 --- /dev/null +++ b/cmd/aerial/embed.go @@ -0,0 +1,236 @@ +package main + +import "github.com/bwmarrin/discordgo" + +//Embed ... +type Embed struct { + *discordgo.MessageEmbed +} + +// Constants for message embed character limits +const ( + EmbedLimitTitle = 256 + EmbedLimitDescription = 2048 + EmbedLimitFieldValue = 1024 + EmbedLimitFieldName = 256 + EmbedLimitField = 25 + EmbedLimitFooter = 2048 + EmbedLimit = 4000 +) + +//NewEmbed returns a new embed object +func NewEmbed() *Embed { + return &Embed{&discordgo.MessageEmbed{}} +} + +//SetTitle ... +func (e *Embed) SetTitle(name string) *Embed { + e.Title = name + return e +} + +//SetDescription [desc] +func (e *Embed) SetDescription(description string) *Embed { + if len(description) > 2048 { + description = description[:2048] + } + e.Description = description + return e +} + +//AddField [name] [value] +func (e *Embed) AddField(name, value string) *Embed { + if len(value) > 1024 { + value = value[:1024] + } + + if len(name) > 1024 { + name = name[:1024] + } + + e.Fields = append(e.Fields, &discordgo.MessageEmbedField{ + Name: name, + Value: value, + }) + + return e + +} + +//SetFooter [Text] [iconURL] +func (e *Embed) SetFooter(args ...string) *Embed { + iconURL := "" + text := "" + proxyURL := "" + + switch { + case len(args) > 2: + proxyURL = args[2] + fallthrough + case len(args) > 1: + iconURL = args[1] + fallthrough + case len(args) > 0: + text = args[0] + case len(args) == 0: + return e + } + + e.Footer = &discordgo.MessageEmbedFooter{ + IconURL: iconURL, + Text: text, + ProxyIconURL: proxyURL, + } + + return e +} + +//SetImage ... +func (e *Embed) SetImage(args ...string) *Embed { + var URL string + var proxyURL string + + if len(args) == 0 { + return e + } + if len(args) > 0 { + URL = args[0] + } + if len(args) > 1 { + proxyURL = args[1] + } + e.Image = &discordgo.MessageEmbedImage{ + URL: URL, + ProxyURL: proxyURL, + } + return e +} + +//SetThumbnail ... +func (e *Embed) SetThumbnail(args ...string) *Embed { + var URL string + var proxyURL string + + if len(args) == 0 { + return e + } + if len(args) > 0 { + URL = args[0] + } + if len(args) > 1 { + proxyURL = args[1] + } + e.Thumbnail = &discordgo.MessageEmbedThumbnail{ + URL: URL, + ProxyURL: proxyURL, + } + return e +} + +//SetAuthor ... +func (e *Embed) SetAuthor(args ...string) *Embed { + var ( + name string + iconURL string + URL string + proxyURL string + ) + + if len(args) == 0 { + return e + } + if len(args) > 0 { + name = args[0] + } + if len(args) > 1 { + iconURL = args[1] + } + if len(args) > 2 { + URL = args[2] + } + if len(args) > 3 { + proxyURL = args[3] + } + + e.Author = &discordgo.MessageEmbedAuthor{ + Name: name, + IconURL: iconURL, + URL: URL, + ProxyIconURL: proxyURL, + } + + return e +} + +//SetURL ... +func (e *Embed) SetURL(URL string) *Embed { + e.URL = URL + return e +} + +//SetColor ... +func (e *Embed) SetColor(clr int) *Embed { + e.Color = clr + return e +} + +// InlineAllFields sets all fields in the embed to be inline +func (e *Embed) InlineAllFields() *Embed { + for _, v := range e.Fields { + v.Inline = true + } + return e +} + +// Truncate truncates any embed value over the character limit. +func (e *Embed) Truncate() *Embed { + e.TruncateDescription() + e.TruncateFields() + e.TruncateFooter() + e.TruncateTitle() + return e +} + +// TruncateFields truncates fields that are too long +func (e *Embed) TruncateFields() *Embed { + if len(e.Fields) > 25 { + e.Fields = e.Fields[:EmbedLimitField] + } + + for _, v := range e.Fields { + + if len(v.Name) > EmbedLimitFieldName { + v.Name = v.Name[:EmbedLimitFieldName] + } + + if len(v.Value) > EmbedLimitFieldValue { + v.Value = v.Value[:EmbedLimitFieldValue] + } + + } + return e +} + +// TruncateDescription ... +func (e *Embed) TruncateDescription() *Embed { + if len(e.Description) > EmbedLimitDescription { + e.Description = e.Description[:EmbedLimitDescription] + } + return e +} + +// TruncateTitle ... +func (e *Embed) TruncateTitle() *Embed { + if len(e.Title) > EmbedLimitTitle { + e.Title = e.Title[:EmbedLimitTitle] + } + return e +} + +// TruncateFooter ... +func (e *Embed) TruncateFooter() *Embed { + if e.Footer != nil && len(e.Footer.Text) > EmbedLimitFooter { + e.Footer.Text = e.Footer.Text[:EmbedLimitFooter] + } + return e +}
\ No newline at end of file diff --git a/cmd/aerial/hipster.go b/cmd/aerial/hipster.go new file mode 100644 index 0000000..e6728c5 --- /dev/null +++ b/cmd/aerial/hipster.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "strings" + + "github.com/bwmarrin/discordgo" +) + +func hipster(s *discordgo.Session, m *discordgo.Message, parv []string) error { + msg, err := getHipsterText() + if err != nil { + return err + } + + s.ChannelMessageSend(m.ChannelID, msg) + return nil +} + +func getHipsterText() (string, error) { + resp, err := http.Get("http://hipsterjesus.com/api/?type=hipster-centric&html=false") // paras parameter no longer respected, need to re-implement locally + if err != nil { + return "", err + } + + textStruct := &struct { + Text string `json:"text"` + }{} + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + + json.Unmarshal(body, textStruct) + + text := strings.Split(textStruct.Text, ". ")[0] + textSlice := strings.Split(text, " ") // Separate each word into an array + truncatedText := "" + + wordCount := 5 // change this to adjust word count + + for i := 0; i < wordCount; i++ { + truncatedText += textSlice[i] + " " + } + + return truncatedText, nil +} diff --git a/cmd/aerial/main.go b/cmd/aerial/main.go new file mode 100644 index 0000000..e62e977 --- /dev/null +++ b/cmd/aerial/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "regexp" + "time" + + "github.com/bwmarrin/discordgo" + _ "github.com/joho/godotenv/autoload" + "within.website/x/internal/pvfm/bot" + "within.website/x/internal/pvfm/commands/source" +) + +type aerial struct { + cs *bot.CommandSet + s *discordgo.Session +} + +const ( + djonHelp = `` + djoffHelp = `` + setupHelp = `` +) + +func (a *aerial) Handle(s *discordgo.Session, m *discordgo.MessageCreate) { + err := a.cs.Run(s, m.Message) + if err != nil { + log.Println(err) + } +} + +var ( + token = os.Getenv("TOKEN") + youtubeSpamRoomID = os.Getenv("DISCORD_YOUTUBESPAM_ROOMID") + gClientID = os.Getenv("GOOGLE_CLIENT_ID") + gClientSecret = os.Getenv("GOOGLE_CLIENT_SECRET") + + musicLinkRegex = regexp.MustCompile(`(.*)((http(s?):\/\/(www\.)?soundcloud.com\/.*)|(http(s?):\/\/(www\.)?youtube.com\/.*)|(http(s?):\/\/(www\.)?youtu.be\/.*))(.*)|(.*)http(s?):\/\/(www\.)?mixcloud.com\/.*`) +) + +func main() { + flag.Parse() + dg, err := discordgo.New("Bot " + token) + if err != nil { + log.Fatal(err) + } + + a := &aerial{ + cs: bot.NewCommandSet(), + s: dg, + } + + a.cs.Prefix = ";" + a.cs.AddCmd("np", "shows radio station statistics for Ponyville FM", bot.NoPermissions, stats) + a.cs.AddCmd("stats", "shows radio station statistics for Ponyville FM", bot.NoPermissions, stats) + a.cs.AddCmd("dj", "shows which DJ is up next on Ponyville FM", bot.NoPermissions, stats) + a.cs.AddCmd("schedule", "shows the future radio schedule for Ponyville FM", bot.NoPermissions, schedule) + a.cs.AddCmd("hipster", "hip me up fam", bot.NoPermissions, hipster) + a.cs.AddCmd("source", "source code information", bot.NoPermissions, source.Source) + a.cs.AddCmd("time", "shows the current bot time", bot.NoPermissions, curTime) + a.cs.AddCmd("streams", "shows the different Ponyville FM stream links", bot.NoPermissions, streams) + a.cs.AddCmd("servers", "shows the different Ponyville FM stream links", bot.NoPermissions, streams) + a.cs.AddCmd("derpi", "grabs a random **__safe__** image from Derpibooru with the given search results", bot.NoPermissions, derpi) + a.cs.AddCmd("weather", "how's the weather right now?", bot.NoPermissions, weather) + + dg.AddHandler(a.Handle) + dg.AddHandler(pesterLink) + dg.AddHandler(messageCreate) + + err = dg.Open() + if err != nil { + log.Fatal(err) + } + + log.Println("ready") + + <-make(chan struct{}) +} + +// This function will be called (due to AddHandler above) every time a new +// message is created on any channel that the autenticated bot has access to. +func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { + // Print message to stdout. + fmt.Printf("%20s %20s %20s %20s > %s\n", m.Author.ID, m.ChannelID, time.Now().Format(time.Stamp), m.Author.Username, m.Content) +} diff --git a/cmd/aerial/pvfm.go b/cmd/aerial/pvfm.go new file mode 100644 index 0000000..97f13bb --- /dev/null +++ b/cmd/aerial/pvfm.go @@ -0,0 +1,260 @@ +package main + +import ( + "fmt" + "log" + "math/rand" + "strconv" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + derpiSearch "within.website/x/cmd/aerial/derpi" + "within.website/x/internal/pvfm" + pvfmschedule "within.website/x/internal/pvfm/schedule" + "within.website/x/internal/pvfm/station" +) + +func init() { + rand.Seed(time.Now().Unix()) +} + +// randomRange gives a random whole integer between the given integers [min, max) +func randomRange(min, max int) int { + return rand.Intn(max-min) + min +} + +func pesterLink(s *discordgo.Session, m *discordgo.MessageCreate) { + if musicLinkRegex.Match([]byte(m.Content)) { + i, err := pvfm.GetStats() + if err != nil { + log.Println(err) + return + } + + if i.IsDJLive() && m.ChannelID == youtubeSpamRoomID { + s.ChannelMessageSend(m.ChannelID, "Please be mindful sharing links to music when a DJ is performing. Thanks!") + } + } +} + +func stats(s *discordgo.Session, m *discordgo.Message, parv []string) error { + i, err := pvfm.GetStats() + if err != nil { + log.Printf("Error getting the station info: %v, falling back to plan b", err) + return doStatsFromStation(s, m, parv) + } + + st, err := station.GetStats() + if err != nil { + return err + } + + var l int + var peak int + + for _, source := range st.Icestats.Source { + l = l + source.Listeners + peak = peak + source.ListenerPeak + } + + // checks if the event is currently happening + outputEmbed := NewEmbed(). + SetTitle("Listener Statistics"). + SetDescription("Use `;streams` if you need a link to the radio!\nTotal listeners across all stations: " + strconv.Itoa(i.Listeners.Listeners) + " with a maximum of " + strconv.Itoa(peak) + ".") + + outputEmbed.AddField("🎵 Main", strconv.Itoa(i.Main.Listeners)+" listeners.\n"+i.Main.Nowplaying) + outputEmbed.AddField("🎵 Chill", strconv.Itoa(i.Secondary.Listeners)+" listeners.\n"+i.Secondary.Nowplaying) + outputEmbed.AddField("🎵 Free! (no DJ sets)", strconv.Itoa(i.MusicOnly.Listeners)+" listeners.\n"+i.MusicOnly.Nowplaying) + + outputEmbed.InlineAllFields() + + s.ChannelMessageSendEmbed(m.ChannelID, outputEmbed.MessageEmbed) + + return nil +} + +func schedule(s *discordgo.Session, m *discordgo.Message, parv []string) error { + schEntries, err := pvfmschedule.Get() + if err != nil { + return err + } + + // Create embed object + outputEmbed := NewEmbed(). + SetTitle("Upcoming Shows"). + SetDescription("These are the upcoming shows and events airing soon on PVFM 1.\n[Convert to your timezone](https://www.worldtimebuddy.com/?pl=1&lid=100&h=100)") + + for _, entry := range schEntries { + + // Format countdown timer + startTimeUnix := time.Unix(int64(entry.StartUnix), 0) + nowWithoutNanoseconds := time.Unix(time.Now().Unix(), 0) + dur := startTimeUnix.Sub(nowWithoutNanoseconds) + + // Show "Live Now!" if the timer is less than 0h0m0s + if dur > 0 { + outputEmbed.AddField(":musical_note: "+entry.Host+" - "+entry.Name, entry.StartTime+" "+entry.Timezone+"\nAirs in "+dur.String()) + } else { + outputEmbed.AddField(":musical_note: "+entry.Host+" - "+entry.Name, "Live now!") + } + } + + s.ChannelMessageSendEmbed(m.ChannelID, outputEmbed.MessageEmbed) + return nil +} + +func doStationRequest(s *discordgo.Session, m *discordgo.Message, parv []string) error { + stats, err := station.GetStats() + if err != nil { + return err + } + + result := fmt.Sprintf( + "Now playing: %s - %s on Ponyville FM!", + stats.Icestats.Source[0].Title, + stats.Icestats.Source[0].Artist, + ) + + s.ChannelMessageSend(m.ChannelID, result) + return nil +} + +func doStatsFromStation(s *discordgo.Session, m *discordgo.Message, parv []string) error { + st, err := station.GetStats() + if err != nil { + return err + } + + var l int + var peak int + + for _, source := range st.Icestats.Source { + l = l + source.Listeners + peak = peak + source.ListenerPeak + } + + result := []string{ + fmt.Sprintf("Current listeners: %d with a maximum of %d!", l, peak), + } + + s.ChannelMessageSend(m.ChannelID, strings.Join(result, "\n")) + return nil +} + +func curTime(s *discordgo.Session, m *discordgo.Message, parv []string) error { + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("The time currently is %s\nUse <https://www.worldtimebuddy.com/?pl=1&lid=100&h=100> to convert UTC to your local timezone.", time.Now().UTC().Format("2006-01-02 15:04:05 UTC"))) + + return nil +} + +const pvfmList = `SSL SAFE Streams +PonyvilleFM Europe OGG Stream: +https://dj.bronyradio.com/pvfm1.ogg +PVFM AAC+ 3G/4G Mobile Stream: +https://dj.bronyradio.com/pvfm1mobile.aac +PonyvilleFM Free MP3 24/7 Pony Stream: +https://dj.bronyradio.com/pvfmfree.mp3 +PonyvilleFM Free OGG 24/7 Pony Stream: +https://dj.bronyradio.com/pvfmfree.ogg +PVFM OPUS Stream: +https://dj.bronyradio.com/pvfmopus.ogg +PonyvilleFM Europe Stream: +https://dj.bronyradio.com/stream.mp3 +PonyvilleFM High Quality Europe Stream: +https://dj.bronyradio.com/streamhq.mp3 + +Legacy Streams (non https) +PonyvilleFM Europe OGG Stream: +http://dj.bronyradio.com:8000/pvfm1.ogg +PonyvilleFM Europe Stream: +http://dj.bronyradio.com:8000/stream.mp3 +PonyvilleFM Free MP3 24/7 Pony Stream: +http://dj.bronyradio.com:8000/pvfmfree.mp3 +PonyvilleFM Free OGG 24/7 Pony Stream: +http://dj.bronyradio.com:8000/pvfmfree.ogg +PVFM AAC+ 3G/4G Mobile Stream: +http://dj.bronyradio.com:8000/pvfm1mobile.aac` + +func streams(s *discordgo.Session, m *discordgo.Message, parv []string) error { + // start building custom embed + outputEmbed := NewEmbed(). + SetTitle("Stream Links"). + SetDescription("These are direct feeds of the live streams; most browsers and media players can play them!") + + // PVFM + outputEmbed.AddField(":musical_note: PVFM Servers", pvfmList) + // Luna Radio + outputEmbed.AddField(":musical_note: Luna Radio Servers", "Luna Radio MP3 128Kbps Stream:\n<http://radio.ponyvillelive.com:8002/stream.mp3>\nLuna Radio Mobile MP3 64Kbps Stream:\n<http://radio.ponyvillelive.com:8002/mobile?;stream.mp3>\n") + // Recordings + outputEmbed.AddField(":cd: DJ Recordings", "Archive\n<https://pvfm.within.lgbt/var/93252527679639552/>\nLegacy Archive\n<https://pvfm.within.lgbt/BronyRadio/>") + + s.ChannelMessageSendEmbed(m.ChannelID, outputEmbed.MessageEmbed) + + // no errors yay!!!! + return nil +} + +func derpi(s *discordgo.Session, m *discordgo.Message, parv []string) error { + if m.ChannelID == "292755043684450304" { + + searchResults, err := derpiSearch.SearchDerpi(m.Content[7:len(m.Content)]) // Safe tag will be added in derpi/derpi.go + if err != nil { + s.ChannelMessageSend(m.ChannelID, "An error occured.") + return err + } + if len(searchResults.Search) < 1 { + s.ChannelMessageSend(m.ChannelID, "Error: No results") + return nil + } + derpiImage := searchResults.Search[randomRange(0, len(searchResults.Search))] + + tags := strings.Split(derpiImage.Tags, ", ") // because this isn't an array for some reason + + // Check for artist tag + artist := "" + for _, tag := range tags { + if strings.Contains(tag, "artist:") { + artist = tag[7:] + } + } + + outputEmbed := NewEmbed(). + SetTitle("Derpibooru Image"). + SetURL("https://derpibooru.org/" + derpiImage.ID). + SetDescription(derpiImage.Description). + SetImage("http:" + derpiImage.Image). + SetFooter("Image score: " + strconv.Itoa(derpiImage.Score) + " | Uploaded: " + derpiImage.CreatedAt.String()) + + // Credit the artist! + if artist == "" { + outputEmbed.SetAuthor("No artist") + } else { + outputEmbed.SetAuthor("Artist: " + artist) + } + + s.ChannelMessageSendEmbed(m.ChannelID, outputEmbed.MessageEmbed) + } else { + s.ChannelMessageSend(m.ChannelID, "Please use this command in <#292755043684450304> only.") + } + return nil +} + +func weather(s *discordgo.Session, m *discordgo.Message, parv []string) error { + responses := []string{ + "Cloudy with a chance of meatballs.", + "It's currently pouring down even more than Pinkie.", + "It's the most overcast I've ever seen. In other words, same as always.", + "Do you have a better conversation starter than that?", + "There's at least 5 or 6 weather right now, my dude.", + "It's soggy enough for Rainbow Dash to get fired, if she didn't have a literal deity keeping her in charge.", + "Surprisingly, the weather is pretty alright.", + "You'd be happy to know that it's hot enough to make a phoenix sweat.", + "The weather right now is like you took London and stuck it in a dishwasher.", + "The Crystal Empire is warmer than this weather.", + } + + s.ChannelMessageSend(m.ChannelID, responses[randomRange(0, len(responses))]) + + return nil +} diff --git a/internal/pvfm/bot/bot.go b/internal/pvfm/bot/bot.go new file mode 100644 index 0000000..5ed093d --- /dev/null +++ b/internal/pvfm/bot/bot.go @@ -0,0 +1,181 @@ +package bot + +import ( + "errors" + "log" + "strings" + "sync" + "time" + + "golang.org/x/time/rate" + + "github.com/bwmarrin/discordgo" +) + +var ( + ErrRateLimitExceeded = errors.New("bot: per-command rate limit exceeded") +) + +type command struct { + aliases []string + verb string + helptext string +} + +func (c *command) Verb() string { + return c.verb +} + +func (c *command) Helptext() string { + return c.helptext +} + +// Handler is the type that bot command functions need to implement. Errors +// should be returned. +type Handler func(*discordgo.Session, *discordgo.Message, []string) error + +// CommandHandler is a generic interface for types that implement a bot +// command. It is akin to http.Handler, but more comprehensive. +type CommandHandler interface { + Verb() string + Helptext() string + + Handler(*discordgo.Session, *discordgo.Message, []string) error + Permissions(*discordgo.Session, *discordgo.Message, []string) error +} + +type basicCommand struct { + *command + handler Handler + permissions Handler + limiter *rate.Limiter +} + +func (bc *basicCommand) Handler(s *discordgo.Session, m *discordgo.Message, parv []string) error { + return bc.handler(s, m, parv) +} + +func (bc *basicCommand) Permissions(s *discordgo.Session, m *discordgo.Message, parv []string) error { + if !bc.limiter.Allow() { + return ErrRateLimitExceeded + } + + return bc.permissions(s, m, parv) +} + +// The "default" command set, useful for simple bot projects. +var ( + DefaultCommandSet = NewCommandSet() +) + +// Command handling errors. +var ( + ErrAlreadyExists = errors.New("bot: command already exists") + ErrNoSuchCommand = errors.New("bot: no such command exists") + ErrNoPermissions = errors.New("bot: you do not have permissions for this command") + ErrParvCountMismatch = errors.New("bot: parameter count mismatch") +) + +// The default command prefix. Command `foo` becomes `.foo` in chat, etc. +const ( + DefaultPrefix = "." +) + +// NewCommand creates an anonymous command and adds it to the default CommandSet. +func NewCommand(verb, helptext string, handler, permissions Handler) error { + return DefaultCommandSet.Add(NewBasicCommand(verb, helptext, handler, permissions)) +} + +// NewBasicCommand creates a CommandHandler instance using the implementation +// functions supplied as arguments. +func NewBasicCommand(verb, helptext string, permissions, handler Handler) CommandHandler { + return &basicCommand{ + command: &command{ + verb: verb, + helptext: helptext, + }, + handler: handler, + permissions: permissions, + limiter: rate.NewLimiter(rate.Every(5*time.Second), 1), + } +} + +// CommandSet is a group of bot commands similar to an http.ServeMux. +type CommandSet struct { + sync.Mutex + cmds map[string]CommandHandler + + Prefix string +} + +// NewCommandSet creates a new command set with the `help` command pre-loaded. +func NewCommandSet() *CommandSet { + cs := &CommandSet{ + cmds: map[string]CommandHandler{}, + Prefix: DefaultPrefix, + } + + cs.AddCmd("help", "Shows help for the bot", NoPermissions, cs.help) + + return cs +} + +// NoPermissions is a simple middelware function that allows all command invocations +// to pass the permissions check. +func NoPermissions(s *discordgo.Session, m *discordgo.Message, parv []string) error { + return nil +} + +// AddCmd is syntactic sugar for cs.Add(NewBasicCommand(args...)) +func (cs *CommandSet) AddCmd(verb, helptext string, permissions, handler Handler) error { + return cs.Add(NewBasicCommand(verb, helptext, permissions, handler)) +} + +// Add adds a single command handler to the CommandSet. This can be done at runtime +// but it is suggested to only add commands on application boot. +func (cs *CommandSet) Add(h CommandHandler) error { + cs.Lock() + defer cs.Unlock() + + v := strings.ToLower(h.Verb()) + + if _, ok := cs.cmds[v]; ok { + return ErrAlreadyExists + } + + cs.cmds[v] = h + + return nil +} + +// Run makes a CommandSet compatible with discordgo event dispatching. +func (cs *CommandSet) Run(s *discordgo.Session, msg *discordgo.Message) error { + cs.Lock() + defer cs.Unlock() + + if strings.HasPrefix(msg.Content, cs.Prefix) { + params := strings.Fields(msg.Content) + verb := strings.ToLower(params[0][1:]) + + cmd, ok := cs.cmds[verb] + if !ok { + return ErrNoSuchCommand + } + + err := cmd.Permissions(s, msg, params) + if err != nil { + log.Printf("Permissions error: %s: %v", msg.Author.Username, err) + s.ChannelMessageSend(msg.ChannelID, "You don't have permissions for that, sorry.") + return ErrNoPermissions + } + + err = cmd.Handler(s, msg, params) + if err != nil { + log.Printf("command handler error: %v", err) + s.ChannelMessageSend(msg.ChannelID, "error when running that command: "+err.Error()) + return err + } + } + + return nil +} diff --git a/internal/pvfm/bot/doc.go b/internal/pvfm/bot/doc.go new file mode 100644 index 0000000..d5c5af5 --- /dev/null +++ b/internal/pvfm/bot/doc.go @@ -0,0 +1,10 @@ +/* +Package bot contains some generically useful bot rigging for Discord chatbots. + +This package works by defining command handlers in a CommandSet, and then dispatching +based on message contents. If the bot's command prefix is `;`, then `;foo` activates +the command handler for command `foo`. + +A CommandSet has a mutex baked into it for convenience of command implementation. +*/ +package bot diff --git a/internal/pvfm/bot/help.go b/internal/pvfm/bot/help.go new file mode 100644 index 0000000..7e62ff2 --- /dev/null +++ b/internal/pvfm/bot/help.go @@ -0,0 +1,38 @@ +package bot + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +func (cs *CommandSet) help(s *discordgo.Session, m *discordgo.Message, parv []string) error { + switch len(parv) { + case 1: + result := cs.formHelp() + + authorChannel, err := s.UserChannelCreate(m.Author.ID) + if err != nil { + return err + } + + s.ChannelMessageSend(authorChannel.ID, result) + + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("<@%s> check direct messages, help is there!", m.Author.ID)) + + default: + return ErrParvCountMismatch + } + + return nil +} + +func (cs *CommandSet) formHelp() string { + result := "Bot commands: \n" + + for verb, cmd := range cs.cmds { + result += fmt.Sprintf("%s%s: %s\n", cs.Prefix, verb, cmd.Helptext()) + } + + return (result + "If there's any problems please don't hesitate to ask a server admin for help.") +} diff --git a/internal/pvfm/commands/source/source.go b/internal/pvfm/commands/source/source.go new file mode 100644 index 0000000..d72f70c --- /dev/null +++ b/internal/pvfm/commands/source/source.go @@ -0,0 +1,8 @@ +package source + +import "github.com/bwmarrin/discordgo" + +func Source(s *discordgo.Session, m *discordgo.Message, parv []string) error { + s.ChannelMessageSend(m.ChannelID, "Source code: https://github.com/PonyvilleFM/aura") + return nil +} diff --git a/internal/pvfm/fname.go b/internal/pvfm/fname.go new file mode 100644 index 0000000..fa84695 --- /dev/null +++ b/internal/pvfm/fname.go @@ -0,0 +1,28 @@ +package pvfm +< |
