aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Earthfile10
-rw-r--r--cmd/aerial/.gitignore1
-rw-r--r--cmd/aerial/derpi/derpi.go87
-rw-r--r--cmd/aerial/embed.go236
-rw-r--r--cmd/aerial/hipster.go50
-rw-r--r--cmd/aerial/main.go88
-rw-r--r--cmd/aerial/pvfm.go260
-rw-r--r--internal/pvfm/bot/bot.go181
-rw-r--r--internal/pvfm/bot/doc.go10
-rw-r--r--internal/pvfm/bot/help.go38
-rw-r--r--internal/pvfm/commands/source/source.go8
-rw-r--r--internal/pvfm/fname.go28
-rw-r--r--internal/pvfm/info.go105
-rw-r--r--internal/pvfm/pvl/pvl.go77
-rw-r--r--internal/pvfm/recording/doc.go4
-rw-r--r--internal/pvfm/recording/recording.go120
-rw-r--r--internal/pvfm/recording/recording_demo.go59
-rw-r--r--internal/pvfm/schedule/schedule.go102
-rw-r--r--internal/pvfm/station/station.go96
-rw-r--r--kube/alrest/pvfm/aerial/1password.yaml8
-rw-r--r--kube/alrest/pvfm/aerial/deployment.yaml44
-rw-r--r--kube/alrest/pvfm/aerial/kustomization.yaml4
22 files changed, 1616 insertions, 0 deletions
diff --git a/Earthfile b/Earthfile
index 89a8502..54f4e19 100644
--- a/Earthfile
+++ b/Earthfile
@@ -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
+<