From 6146800788c3b4909dfd7964920b1c70875a55c8 Mon Sep 17 00:00:00 2001 From: Matthieu Lalonde Date: Mon, 25 Jan 2021 18:30:53 +0000 Subject: [PATCH] Initial import... --- .gitignore | 4 + cmd/altvrbot/main.go | 26 ++ go.mod | 13 + go.sum | 34 +++ pkg/altvr/altvr.go | 280 +++++++++++++++++++++ pkg/altvr/fetch.go | 232 +++++++++++++++++ pkg/altvr/request.go | 204 +++++++++++++++ pkg/altvr/schemas.go | 276 ++++++++++++++++++++ pkg/bot/bot.go | 318 +++++++++++++++++++++++ pkg/bot/excuses.go | 544 ++++++++++++++++++++++++++++++++++++++++ pkg/bot/handlers.go | 517 ++++++++++++++++++++++++++++++++++++++ pkg/discord/discord.go | 110 ++++++++ pkg/discord/mux/help.go | 81 ++++++ pkg/discord/mux/mux.go | 195 ++++++++++++++ 14 files changed, 2834 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/altvrbot/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/altvr/altvr.go create mode 100644 pkg/altvr/fetch.go create mode 100644 pkg/altvr/request.go create mode 100644 pkg/altvr/schemas.go create mode 100644 pkg/bot/bot.go create mode 100644 pkg/bot/excuses.go create mode 100644 pkg/bot/handlers.go create mode 100644 pkg/discord/discord.go create mode 100644 pkg/discord/mux/help.go create mode 100644 pkg/discord/mux/mux.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f70911 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +*~ +bin/ +users.json \ No newline at end of file diff --git a/cmd/altvrbot/main.go b/cmd/altvrbot/main.go new file mode 100644 index 0000000..f285944 --- /dev/null +++ b/cmd/altvrbot/main.go @@ -0,0 +1,26 @@ +package main + +/* +TODO: Save cookie file +TODO: https://github.com/spf13/viper +TODO: https://github.com/sirupsen/logrus / https://github.com/golang/glog +TODO: https://github.com/juju/errors +*/ + +import ( + "os" + "os/signal" + "syscall" + + "git.lalonde.me/matth/AltVRBot/pkg/bot" +) + +func main() { + Bot := &bot.Type{} + Bot.Start() + defer Bot.Close() + + sc := make(chan os.Signal, 1) + signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill) + <-sc +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7493461 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module git.lalonde.me/matth/AltVRBot + +go 1.15 + +require ( + github.com/PuerkitoBio/goquery v1.6.1 + github.com/andybalholm/cascadia v1.2.0 // indirect + github.com/bwmarrin/discordgo v0.22.1 + github.com/gorilla/websocket v1.4.2 // indirect + golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect + golang.org/x/net v0.0.0-20210119194325-5f4716e94777 + golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..31bf80e --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/PuerkitoBio/goquery v1.6.1 h1:FgjbQZKl5HTmcn4sKBgvx8vv63nhyhIpv7lJpFGCWpk= +github.com/PuerkitoBio/goquery v1.6.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= +github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/andybalholm/cascadia v1.2.0 h1:vuRCkM5Ozh/BfmsaTm26kbjm0mIOM3yS5Ek/F5h18aE= +github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY= +github.com/bwmarrin/discordgo v0.22.1 h1:254fNYyfqJWKbPzO5g8j/nUvRgj4dNlI19EB8rnkpt8= +github.com/bwmarrin/discordgo v0.22.1/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16 h1:y6ce7gCWtnH+m3dCjzQ1PCuwl28DDIc3VNnvY29DlIA= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/pkg/altvr/altvr.go b/pkg/altvr/altvr.go new file mode 100644 index 0000000..34c7b59 --- /dev/null +++ b/pkg/altvr/altvr.go @@ -0,0 +1,280 @@ +package altvr + +import ( + "log" + "net/http/cookiejar" + + "golang.org/x/net/publicsuffix" +) + +// XXX: Hold a HTTP CONNECT / Keep-Alive so we show up as online + +// AltVR Client Type +type AltVR struct { + jar *cookiejar.Jar + username string + password string + csrfToken string // CSRF token for requests + userID string // Our own user id + user User // Cache of our own user data + friends []User + friendships []Friendship + onlineFriendships []Friendship +} + +// DisconnectFunc handler for disconnection +type DisconnectFunc func(User) + +// ConnectFunc handler for connection +type ConnectFunc func(User) + +// New creates a new instance of the AltVR client +// Takes an account username and password as parameters +func New(u, p string) AltVR { + avr := AltVR{username: u, password: p} + var err error + avr.jar, err = cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + if err != nil { + log.Fatal(err) + } + err = avr.doLogin() + if err != nil { + log.Fatal(err) + } + user, err := avr.fetchMyUser() + if err != nil { + log.Fatal(err) + } + avr.user = user + if err := avr.FetchFriendships(); err != nil { + log.Fatal(err) + } + return avr +} + +// GetAvatarURL returns the cached user avatar url +func (avr *AltVR) GetAvatarURL() string { + return avr.user.ProfileImage +} + +// FetchMyUser fetches our own user data and cache it +func (avr *AltVR) FetchMyUser() error { + user, err := avr.fetchMyUser() + + if err == errorLogin { + log.Println("Not logged in, doing login") + err = avr.doLogin() + if err != nil { + log.Fatal(err) + } + user, err = avr.fetchMyUser() + } + if err != nil { + return err + } + avr.user = user + return nil +} + +// GetMyUser Returns the cached copy of our user data +func (avr *AltVR) GetMyUser() User { + return avr.user +} + +// FetchClientIP retrives the client IP from the API +func (avr *AltVR) FetchClientIP() (string, error) { + return avr.fetchClientIP() +} + +// FetchFriendships fetches all our friends' user data and caches it +func (avr *AltVR) FetchFriendships() error { + var err error + friendships, err := avr.fetchFriendships() + if err != nil { + return err + } + for _, friendship := range friendships { + found := false + for _, ff := range avr.friendships { + if friendship.FriendID == ff.FriendID { + found = true + break + } + } + if !found { + if err := avr.FetchFriend(friendship.FriendID); err != nil { + log.Printf("Error fetching friend user data: %+v\n", err) + } + } + } + avr.friendships = friendships + return nil +} + +// GetFriendships returns the cached list of friendships +func (avr *AltVR) GetFriendships() []Friendship { + return avr.friendships +} + +// FetchOnlineFriendships check for changes in the online friends list +// Triggers the callback functions on dis/connect +func (avr *AltVR) FetchOnlineFriendships(cfn ConnectFunc, dfn DisconnectFunc) error { + friendships, err := avr.fetchFriendshipsOnline() + if err != nil { + return err + } + + for _, friendship := range friendships { + found := false + for _, of := range avr.onlineFriendships { + if friendship.FriendID == of.FriendID { + found = true + break + } + } + if !found { + if err := avr.FetchFriend(friendship.FriendID); err != nil { + return err + } + cfn(avr.GetFriend(friendship.FriendID)) + } + } + + for _, friendship := range avr.onlineFriendships { + found := false + for _, of := range friendships { + if friendship.FriendID == of.FriendID { + found = true + break + } + } + if !found { + if err := avr.FetchFriend(friendship.FriendID); err != nil { + return err + } + dfn(avr.GetFriend(friendship.FriendID)) + } + } + + avr.onlineFriendships = friendships + return nil +} + +// FetchFriend fetches one user's data and update the cache +func (avr *AltVR) FetchFriend(userID string) error { + users, err := avr.fetchUsers(userID) + if err != nil { + return err + } + user := users[0] + found := false + for k, u := range avr.friends { + if u.UserID == user.UserID { + avr.friends[k] = user + found = true + break + } + } + if !found { + avr.friends = append(avr.friends, user) + } + return nil +} + +// FetchFriends fetches many users' data and update the cache +func (avr *AltVR) FetchFriends(userID ...string) error { + users, err := avr.fetchUsers(userID...) + if err != nil { + return err + } + for _, user := range users { + found := false + for k, u := range avr.friends { + if u.UserID == user.UserID { + avr.friends[k] = user + found = true + break + } + } + if !found { + avr.friends = append(avr.friends, user) + } + } + return nil +} + +// GetFriend gets a cached friend user data with the user ID +func (avr *AltVR) GetFriend(userID string) User { + var user User + for _, u := range avr.friends { + if u.UserID == userID { + user = u + break + } + } + return user +} + +// GetFriendByUsername gets a cached friend user data with the username +func (avr *AltVR) GetFriendByUsername(username string) User { + var user User + for _, u := range avr.friends { + if u.Username == username { + user = u + break + } + } + return user +} + +// FetchPendingFriendshipRequests fetches a list of pending friendship requests +func (avr *AltVR) FetchPendingFriendshipRequests() ([]Friendship, error) { + fr, err := avr.fetchPendingFriendshipRequests() + if err != nil { + log.Printf("Error fetching pending conversations: %+v\n", err) + return fr, err + } + var uids []string + for _, ff := range fr { + uids = append(uids, ff.UserID) + } + avr.FetchFriends(uids...) + return fr, nil +} + +// AcceptFriendshipRequest accepts a pending friendship request +func (avr *AltVR) AcceptFriendshipRequest(FriendshipID string) error { + return avr.acceptPendingFriendshipRequest(FriendshipID) +} + +// DenyFriendshipRequest accepts a pending friendship request +func (avr *AltVR) DenyFriendshipRequest(FriendshipID string) error { + return avr.denyPendingFriendshipRequest(FriendshipID) +} + +// FetchPendingConversations fetches any pending conversations +func (avr *AltVR) FetchPendingConversations() ([]Conversation, error) { + pc, err := avr.fetchPendingConversations() + if err != nil { + log.Printf("Error fetching pending conversations: %+v\n", err) + return pc, err + } + var ccs []Conversation + for _, c := range pc { + if !c.IsRead { + ccs = append(ccs, c) + } + } + return ccs, nil +} + +// AcknowkledgeConversation mark a conversation item as read +func (avr *AltVR) AcknowkledgeConversation(conversationID string) error { + return avr.markConversationAsRead(conversationID) +} + +// PostNewConversation send a new message +func (avr *AltVR) PostNewConversation(userID, message string) error { + // XXX: Truncate 140 chars + return avr.postNewConversation(userID, message) +} diff --git a/pkg/altvr/fetch.go b/pkg/altvr/fetch.go new file mode 100644 index 0000000..99b1715 --- /dev/null +++ b/pkg/altvr/fetch.go @@ -0,0 +1,232 @@ +package altvr + +// TODO: Handle paginates + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/PuerkitoBio/goquery" + "golang.org/x/net/html" +) + +const ( + _UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36" +) + +func (avr *AltVR) doLogin() error { + u, _ := url.Parse("https://account.altvr.com/users/sign_in") + client := &http.Client{Jar: avr.jar} + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + log.Fatalln(err) + } + req.Header.Set("User-Agent", _UA) + + resp, err := client.Do(req) + if err != nil { + log.Fatalln(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("Page unavailable (%d)", resp.StatusCode) + } + + ctype := resp.Header.Get("content-Type") + if !strings.HasPrefix(ctype, "text/html") { + return errors.New("Invalid content type") + } + + tokenizer := html.NewTokenizer(resp.Body) + var authToken string + found := false + for { + tokenType := tokenizer.Next() + + if tokenType == html.ErrorToken { + err := tokenizer.Err() + if err == io.EOF { + break + } + return errors.New("Invalid content") + } + if tokenType == html.SelfClosingTagToken { + token := tokenizer.Token() + if "input" == token.Data { + value := "" + for _, a := range token.Attr { + if a.Key == "name" { + if a.Val == "authenticity_token" { + found = true + } + } + if a.Key == "value" { + value = a.Val + } + } + if found { + authToken = value + break + } + } + } + } + if authToken == "" { + return errors.New("Auth token not found") + } + postData := url.Values{ + "authenticity_token": {authToken}, + "utf8": {"✓"}, + "user[tz_offset]": {"0"}, + "user[remember_me]": {"1"}, + "user[email]": {avr.username}, + "user[password]": {avr.password}, + } + + req, err = http.NewRequest("POST", u.String(), strings.NewReader(postData.Encode())) + if err != nil { + log.Fatalln(err) + } + req.Header.Set("User-Agent", _UA) + + resp, err = client.Do(req) + if err != nil { + log.Fatalln(err) + } + defer resp.Body.Close() + + avr.csrfToken = resp.Header.Get("X-CSRF-Token") + + // Load the HTML document + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return err + } + + hrefProfile, loggedIn := doc.Find("body > div.main-wrapper > div.nav-bar > div > div > div:nth-child(2) > div:nth-child(2) > a").Attr("href") + if !loggedIn { + return errorLogin + } + + avr.userID = strings.Replace(strings.Replace(hrefProfile, "/user_profile", "", -1), "/users/", "", -1) + return nil +} + +func (avr *AltVR) fetchMyUser() (User, error) { + if avr.userID == "" { + return User{}, errorLogin + } + + body, err := avr.get("users/" + avr.userID) + if err != nil { + return User{}, err + } + + var users Users + json.Unmarshal(body, &users) + return users.Users[0], nil +} + +func (avr *AltVR) fetchClientIP() (string, error) { + body, err := avr.get("client_ip.json") + if err != nil { + return "", err + } + + var cip ClientIP + json.Unmarshal(body, &cip) + return cip.ClientIP, nil +} + +func (avr *AltVR) fetchFriendships() ([]Friendship, error) { + body, err := avr.get("friendships/friends?include_users=false&online=false&per=50&page=1") + if err != nil { + return []Friendship{}, err + } + + var friendship Friendships + json.Unmarshal(body, &friendship) + return friendship.Friendships, nil +} + +func (avr *AltVR) fetchFriendshipsOnline() ([]Friendship, error) { + body, err := avr.get("friendships/friends?include_users=false&online=true&per=50&page=1") + if err != nil { + return []Friendship{}, err + } + + var friendship Friendships + json.Unmarshal(body, &friendship) + return friendship.Friendships, nil +} + +func (avr *AltVR) fetchUsers(userIDs ...string) ([]User, error) { + if len(userIDs) == 0 { + return []User{}, nil + } + body, err := avr.get("users/" + strings.Join(userIDs[:], ",")) + if err != nil { + return []User{}, err + } + + var users Users + json.Unmarshal(body, &users) + return users.Users, nil +} + +func (avr *AltVR) fetchPendingFriendshipRequests() ([]Friendship, error) { + body, err := avr.get("friendships/incoming_friend_requests?include_users=false&per=50&page=1") + if err != nil { + return []Friendship{}, err + } + + var friendship Friendships + json.Unmarshal(body, &friendship) + return friendship.Friendships, nil +} + +func (avr *AltVR) acceptPendingFriendshipRequest(friendshipID string) error { + _, err := avr.post("friendships/"+friendshipID+"/accept", strings.NewReader("")) //strings.NewReader(url.Values{}.Encode()) + return err +} + +func (avr *AltVR) denyPendingFriendshipRequest(friendshipID string) error { + _, err := avr.post("friendships/"+friendshipID+"/deny", strings.NewReader("")) + return err +} + +func (avr *AltVR) fetchPendingConversations() ([]Conversation, error) { + body, err := avr.get("conversations") + if err != nil { + return []Conversation{}, err + } + + var conversations Conversations + json.Unmarshal(body, &conversations) + return conversations.Conversations, nil +} + +func (avr *AltVR) postNewConversation(userID, message string) error { + uid, _ := strconv.ParseUint(userID, 10, 64) + jv, _ := json.Marshal(&ConversationNewMessageOutgoing{ + Conversation: ConversationNewMessage{ + UserID: uid, + Subject: message, + }}) + _, err := avr.post("conversations.json", strings.NewReader(string(jv))) + return err +} + +func (avr *AltVR) markConversationAsRead(conversationID string) error { + _, err := avr.post("conversations/"+conversationID+"/mark_as_read", strings.NewReader("")) + return err +} diff --git a/pkg/altvr/request.go b/pkg/altvr/request.go new file mode 100644 index 0000000..c35f9ab --- /dev/null +++ b/pkg/altvr/request.go @@ -0,0 +1,204 @@ +package altvr + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" +) + +/* +X-CSRF-Token: 14VIIeFLgADJVsdemydLkdyEq61KFedTfpC5AGuunv4Svodeu86AysE+VADloZv6c0l9yqCcXWtZ3hP1p93qrQ== +User-Agent: UnityPlayer/2019.4.2f1 (UnityWebRequest/1.0, libcurl/7.52.0-DEV) +X-AppName: altspace_vr_client +X-AppVersion: 4.0.95.d065d +X-AltspaceVR-Version: AltspaceVR-App 4.0.95.d065d +Authorization: Token // _Positron_session +X-Unity-Version: 2019.4.2f1 + +// {"reason":"bad_credentials"} +*/ + +const ( + // Host is the API host + host = "account.altvr.com" +) + +var ( + errorLogin = errors.New("Not logged in") + etags = map[string]etag{} +) + +type etag struct { + key string + date string + body []byte +} + +// Clear HTTP response cache. +func flushEtags() { + for url := range etags { + delete(etags, url) + } +} + +func jarGetToken(jar http.CookieJar, u *url.URL) string { + for _, cookie := range jar.Cookies(u) { + if cookie.Name == "_Positron_session" { + return cookie.Value + } + } + return "" +} + +func (avr *AltVR) reqSetAuthToken(r *http.Request, u *url.URL) { + token := jarGetToken(avr.jar, u) + if token != "" { + r.Header.Set("Authorization", "Token "+token) + } +} + +func (avr *AltVR) get(path string) ([]byte, error) { + apiURL, err := absolutePath(path) + if err != nil { + return nil, err + } + + req, err := avr.buildGetRequest(apiURL) + if err != nil { + return nil, err + } + + client := &http.Client{Jar: avr.jar} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + // Check for no changes + if resp.StatusCode == http.StatusNotModified { + return etags[apiURL.String()].body, nil + } + + if resp.StatusCode == 401 { + return nil, errorLogin + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("Page unavailable (%d)", resp.StatusCode) + } + /* + ctype := resp.Header.Get("content-Type") + if !strings.HasPrefix(ctype, "application/json") { + return "", errors.New("Invalid content type") + } + */ + return avr.updateEtagCache(apiURL.String(), resp) +} + +func (avr *AltVR) post(path string, reader *strings.Reader) ([]byte, error) { + apiURL, err := absolutePath(path) + if err != nil { + return nil, err + } + + req, err := avr.buildPostRequest(apiURL, reader) + if err != nil { + return nil, err + } + + client := &http.Client{Jar: avr.jar} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == 401 { + return nil, errorLogin + } + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + return nil, fmt.Errorf("Page unavailable (%d)", resp.StatusCode) + } + /* + ctype := resp.Header.Get("content-Type") + if !strings.HasPrefix(ctype, "application/json") { + return "", errors.New("Invalid content type") + } + */ + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return body, nil +} + +func (avr *AltVR) updateEtagCache(apiURL string, resp *http.Response) ([]byte, error) { + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + key := resp.Header.Get("ETag") + date := resp.Header.Get("Date") + if key == "" /*|| date == ""*/ { + return body, err + } + etags[apiURL] = etag{key, date, body} + return body, err +} + +func (avr *AltVR) buildRequest(req *http.Request, apiURL *url.URL) (*http.Request, error) { + req.Header.Set("User-Agent", "UnityPlayer/2019.4.2f1 (UnityWebRequest/1.0, libcurl/7.52.0-DEV)") + req.Header.Set("X-AppName", "altspace_vr_client") + req.Header.Set("X-AppVersion", "4.0.95.d065d") + req.Header.Set("X-AltspaceVR-Version", "AltspaceVR-App 4.0.95.d065d") + req.Header.Set("X-Unity-Version", "2019.4.2f1") + req.Header.Set("Accept-Encoding", "identity") + req.Header.Set("X-CSRF-Token", avr.csrfToken) + req.Header.Set("Accept", "*/*") + avr.reqSetAuthToken(req, apiURL) + return req, nil +} + +func (avr *AltVR) buildGetRequest(apiURL *url.URL) (*http.Request, error) { + req, err := http.NewRequest("GET", apiURL.String(), nil) + if err != nil { + return nil, err + } + if _, present := etags[apiURL.String()]; present { + req.Header.Add("If-None-Match", etags[apiURL.String()].key) + //req.Header.Add("If-Modified-Since", etags[apiURL.String()].date) + } + return avr.buildRequest(req, apiURL) +} + +func (avr *AltVR) buildPostRequest(apiURL *url.URL, reader *strings.Reader) (*http.Request, error) { + req, err := http.NewRequest("POST", apiURL.String(), reader) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json; charset=utf8") + return avr.buildRequest(req, apiURL) +} + +func absolutePath(path string) (*url.URL, error) { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + apiURL, err := url.Parse(api() + path) + query := apiURL.Query() + if err != nil { + return nil, err + } + apiURL.RawQuery = query.Encode() + return apiURL, nil +} + +func api() string { + return "https://" + host + "/api" +} diff --git a/pkg/altvr/schemas.go b/pkg/altvr/schemas.go new file mode 100644 index 0000000..3991260 --- /dev/null +++ b/pkg/altvr/schemas.go @@ -0,0 +1,276 @@ +package altvr + +import "time" + +// Friendships json structure for the complete friends data +// URL: https://account.altvr.com/api/friendships/friends?include_users=true&per=50&page=1 +// https://account.altvr.com/api/friendships/outgoing_friend_requests?include_users=false&per=50&page=1 +// https://account.altvr.com/api/friendships/incoming_friend_requests?include_users=false&per=50&page=1 +type Friendships struct { + Friendships []Friendship `json:"friendships"` + Pagination struct { + Page int `json:"page"` + Pages int `json:"pages"` + Count int `json:"count"` + } `json:"pagination"` +} + +// Friendship json structure +// POST https://account.altvr.com/api/friendships/1653976580206100692/accept +// DELETE https://account.altvr.com/api/friendships/1653976580206100692 (empty content) +type Friendship struct { + AasmState string `json:"aasm_state"` // accepted, removed, requested + FriendshipID string `json:"friendship_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + UserID string `json:"user_id"` + FriendID string `json:"friend_id"` + //Friend User `json:"friend,omitempty"` + Friend User `json:"-"` + User User `json:"-"` +} + +// FriendshipRequestAccept return for accepting a frienship request +type FriendshipRequestAccept struct { + AasmState string `json:"aasm_state"` // accepted, removed, requested + FriendshipID uint64 `json:"friendship_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + UserID uint64 `json:"user_id"` + FriendID uint64 `json:"friend_id"` + //Friend User `json:"friend,omitempty"` + Friend User `json:"-"` + User User `json:"-"` +} + +// Conversations json structure +// /api/conversations +type Conversations struct { + Conversations []Conversation `json:"conversations"` + Pagination struct { + Page uint `json:"page"` + Pages uint `json:"pages"` + Count uint `json:"count"` + } `json:"pagination"` +} + +// Conversation structure for a single conversation +type Conversation struct { + Subject string `json:"subject"` + CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + IsRead bool `json:"is_read"` + SenderID string `json:"sender_id"` + ReceiverID string `json:"receiver_id"` +} + +// ConversationsMarkAsRead json structure +// POST https://account.altvr.com/api/conversations/3394100/mark_as_read +type ConversationsMarkAsRead struct { + Subject string `json:"subject"` + CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + IsRead bool `json:"is_read"` + SenderID string `json:"sender_id"` + ReceiverID string `json:"receiver_id"` +} + +// ConversationNewMessageOutgoing json structure +// POST https://account.altvr.com/api/conversations.json +type ConversationNewMessageOutgoing struct { + Conversation ConversationNewMessage `json:"conversation"` +} + +// ConversationNewMessage content of a new conversation message +type ConversationNewMessage struct { + UserID uint64 `json:"user_id"` + Subject string `json:"subject"` +} + +// ConversationNewMessageReturn json structure +type ConversationNewMessageReturn struct { + Subject string `json:"subject"` + CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + IsRead bool `json:"is_read"` + SenderID string `json:"sender_id"` + ReceiverID string `json:"receiver_id"` +} + +// Users json struct +// https://account.altvr.com/api/users/802051693414973874 +// https://account.altvr.com/api/users/1647701242857652976.json +type Users struct { + Users []User `json:"users"` +} + +// User json struct +type User struct { + AdvancedAvatars bool `json:"advanced_avatars"` + PreferredIdentifier string `json:"preferred_identifier"` + NeedsToSetExtendedProfile bool `json:"needs_to_set_extended_profile"` + Roles []string `json:"roles"` + PlatformRoles []string `json:"platform_roles"` + Username string `json:"username"` + FirstName string `json:"first_name"` + Mementos string `json:"mementos,omitempty"` + Bio string `json:"bio,omitempty"` + Guest bool `json:"guest"` + EmailOptIn bool `json:"email_opt_in"` + NeedsToSetBasicProfile bool `json:"needs_to_set_basic_profile"` + LockedAt time.Time `json:"locked_at,omitempty"` + NeedsToSetPassword bool `json:"needs_to_set_password"` + OpusCodecApplication string `json:"opus_codec_application"` + CallButton bool `json:"call_button,omitempty"` + AasmState string `json:"aasm_state"` + Azuread bool `json:"azuread,omitempty"` + TwitterIdentifier string `json:"twitter_identifier,omitempty"` + DiscordIdentifier string `json:"discord_identifier,omitempty"` + StatusMessage string `json:"status_message,omitempty"` + OnlineStatus string `json:"online_status,omitempty"` // Offline, Available + Email string `json:"email,omitempty"` + UnconfirmedEmail bool `json:"unconfirmed_email,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Birthday time.Time `json:"birthday,omitempty"` + Country string `json:"country,omitempty"` + EarlyAccess bool `json:"early_access,omitempty"` + OptIntoDiagnosticData bool `json:"opt_into_diagnostic_data,omitempty"` + LastName string `json:"last_name,omitempty"` + CurrentSpace struct { + SpaceSid string `json:"space_sid"` + Name string `json:"name"` + SpaceNumber string `json:"space_number,omitempty"` + EventID string `json:"event_id"` + SpaceID string `json:"space_id"` + EntryDeniedBecauseFull bool `json:"entry_denied_because_full"` + EntryDeniedBecauseRole bool `json:"entry_denied_because_role"` + } `json:"current_space,omitempty"` + DisplayName string `json:"display_name"` + Streaming bool `json:"streaming"` + UserAvatar struct { + Config struct { + Avatar struct { + AvatarSid string `json:"avatar_sid"` + PrimaryColor []string `json:"primary-color"` + HighlightColor []int `json:"highlight-color"` + } `json:"avatar"` + } `json:"config"` + } `json:"user_avatar,omitempty"` + HomeSpaceID string `json:"home_space_id,omitempty"` + HomeSpaceSid string `json:"home_space_sid,omitempty"` + HomeSpaceVisibility string `json:"home_space_visibility,omitempty"` + Linked bool `json:"linked,omitempty"` + InitialSpaceSid string `json:"initial_space_sid,omitempty"` + ProfileImage string `json:"profile_image,omitempty"` + ProfileImageThumbnail string `json:"profile_image_thumbnail,omitempty"` + Admin bool `json:"admin"` + Online bool `json:"online"` + UserID string `json:"user_id"` + AvatarCustomizationID string `json:"avatar_customization_id"` +} + +// GetDisplayName returns a user's prefered display name +func (u *User) GetDisplayName() string { + if u.PreferredIdentifier == "display_name" { + return u.DisplayName + } + if u.PreferredIdentifier == "last_name" { + return u.LastName + } + if u.PreferredIdentifier == "first_name" { + return u.FirstName + } + return u.Username +} + +// Identity json struct +// https://account.altvr.com/api/users/identity.json +// DO NOT USE!! +type Identity struct { + Users []User `json:"users"` + Settings struct { + MinVersion struct { + MinVersionCheckEnabled bool `json:"min_version_check_enabled"` + MinVersionCheckAltspacevr string `json:"min_version_check_altspacevr"` + } `json:"min_version"` + } `json:"settings,omitempty"` +} + +// Spaces json struct +// +type Spaces struct { + Spaces []struct { + SpaceSid string `json:"space_sid"` + IsFeatured bool `json:"is_featured"` + AltspaceURL string `json:"altspace_url"` + Name string `json:"name"` + Description string `json:"description"` + Layout struct { + } `json:"layout"` + PresenterOnly bool `json:"presenter_only"` + VipOnly bool `json:"vip_only"` + Platform string `json:"platform,omitempty"` + SpaceTag string `json:"space_tag"` + Instructions string `json:"instructions,omitempty"` + TagList []string `json:"tag_list,omitempty"` + SpaceNumber string `json:"space_number,omitempty"` + SoloVisitorShouldResetLayout bool `json:"solo_visitor_should_reset_layout"` + Mementos string `json:"mementos,omitempty"` + FavoriteCount int `json:"favorite_count"` + SpaceID string `json:"space_id"` + SpaceTemplateID string `json:"space_template_id"` + UserID string `json:"user_id"` + Skybox string `json:"skybox,omitempty"` + SpaceTemplateSid string `json:"space_template_sid"` + AssetBundleScenes []struct { + AasmState string `json:"aasm_state"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + AssetBundleSceneID string `json:"asset_bundle_scene_id"` + UserID string `json:"user_id"` + AssetBundles []struct { + GameEngine string `json:"game_engine"` + GameEngineVersion int `json:"game_engine_version"` + Platform string `json:"platform"` + URL string `json:"url"` + Description string `json:"description,omitempty"` + AasmState string `json:"aasm_state"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Layout interface{} `json:"layout"` + Crc string `json:"crc"` + ClientCacheKey string `json:"client_cache_key"` + Name string `json:"name"` + StereoRenderMode bool `json:"stereo_render_mode,omitempty"` + AssetBundleID string `json:"asset_bundle_id"` + UserID string `json:"user_id"` + AssetBundleSceneID string `json:"asset_bundle_scene_id"` + AvatarID string `json:"avatar_id"` + } `json:"asset_bundles"` + } `json:"asset_bundle_scenes"` + WorldID string `json:"world_id"` + AllowsEntry bool `json:"allows_entry"` + Author string `json:"author"` + Favorited bool `json:"favorited"` + DesktopCapacity int `json:"desktop_capacity"` + UgcWarning bool `json:"ugc_warning"` + EntryDeniedBecauseFull bool `json:"entry_denied_because_full"` + EntryDeniedBecauseRole bool `json:"entry_denied_because_role"` + PassphraseRequired bool `json:"passphrase_required,omitempty"` + ImageLarge string `json:"image_large"` + ImageMedium string `json:"image_medium"` + ImageSmall string `json:"image_small"` + ImageThumbnail string `json:"image_thumbnail"` + BannerImageLarge string `json:"banner_image_large"` + BannerImageMedium string `json:"banner_image_medium"` + BannerImageSmall string `json:"banner_image_small"` + } `json:"spaces"` +} + +// ClientIP json struct +// https://account.altvr.com/api/client_ip.json +type ClientIP struct { + ClientIP string `json:"client_ip"` +} diff --git a/pkg/bot/bot.go b/pkg/bot/bot.go new file mode 100644 index 0000000..b20b95a --- /dev/null +++ b/pkg/bot/bot.go @@ -0,0 +1,318 @@ +package bot + +// XXX: Ensure we don't join voice channels +// XXX: Fetch Altspace version and use latest +// XXX: Discord Intents +// XXX: Flirty mode +// XXX: Mlocks on pending requests +// XXX: Rate limits +// XXX: Fetch roles from Guild? +// XXX: Auto accept friendship mode +// XXX: Rude mode enabled only for certain users + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "os" + "time" + + "git.lalonde.me/matth/AltVRBot/pkg/altvr" + "git.lalonde.me/matth/AltVRBot/pkg/discord" +) + +const ( + tickerIntervalFriendshipOnline = 2 + tickerIntervalCheckIncomingConversations = 1 + tickerIntervalCheckIncomingFriendshipRequest = 5 + tickerIntervalUpdateGuildEmojis = 24 * 60 +) + +// UserEmoji holds a guild emoji information for a user +type UserEmoji struct { + ID string + Name string +} + +// User is the structure for a single bot user +type User struct { + AltVRUserID string + DiscordID string + DiscordName string + DiscordEmoji UserEmoji + Role Roles + isRude bool +} + +// Roles determines the bot user roles +type Roles uint + +// Bot user roles +const ( + RoleUser Roles = iota + RoleModerator + RoleAdmin +) + +// FriendshipRequestPending data for a pending friendship request +type FriendshipRequestPending struct { + Friendship altvr.Friendship + MessageID string +} + +// Type the AltVR bot type +type Type struct { + DGToken string + DGsID string + DGcID string + AltVRUsername string + AltVRPassword string + dg *discord.Discord + avr altvr.AltVR + users []User + isRude bool // XXX Trigger automatically on NSFW Channels? + isQuiet bool + frPending []FriendshipRequestPending + convos map[string]string +} + +func (b *Type) setFlags() { + b.DGToken = os.Getenv("DG_TOKEN") + if b.DGToken == "" { + flag.StringVar(&b.DGToken, "t", "", "Discord Authentication Token") + } + b.DGsID = os.Getenv("DG_SERVER_ID") + if b.DGsID == "" { + flag.StringVar(&b.DGsID, "s", "", "Discord Server ID") + } + b.DGcID = os.Getenv("DG_CHANNEL_ID") + if b.DGcID == "" { + flag.StringVar(&b.DGcID, "c", "", "Discord Channel ID") + } + b.AltVRUsername = os.Getenv("ALTVR_USERNAME") + if b.AltVRUsername == "" { + flag.StringVar(&b.AltVRUsername, "u", "", "AltVR Username") + } + b.AltVRPassword = os.Getenv("ALTVR_PASSWORD") + if b.AltVRPassword == "" { + flag.StringVar(&b.AltVRPassword, "p", "", "AltVR Password") + } + flag.BoolVar(&b.isRude, "r", false, "Turn on rude mode") + if !b.isRude { + if value, ok := os.LookupEnv("DG_IS_RUDE"); ok { + b.isRude = (value == "true") + } + } + flag.BoolVar(&b.isQuiet, "q", false, "Turn on quiet mode (don't be verbose on the discord chat)") + flag.Parse() +} + +// Start launches the bot +func (b *Type) Start() { + b.setFlags() + + var err error + b.avr = altvr.New(b.AltVRUsername, b.AltVRPassword) + b.dg, err = discord.New(b.DGToken, b.DGsID, b.DGcID) + if err != nil { + log.Fatal(err) + } + + b.dg.UpdateAvatar(b.avr.GetAvatarURL()) + b.convos = make(map[string]string) + b.loadUserFile() + b.loadDiscordHandlers() + + if !b.isQuiet { + s := b.getReplyMessage("online_welcome") + if _, err := b.dg.Session.ChannelMessageSend(b.DGcID, s); err != nil { + log.Printf("Error sending welcome message: %+v\n", err) + } + } + //b.avr.FetchOnlineFriendships(b.userConnected, b.userDisconnected) + go b.periodicTicker() +} + +// Close closes any open session +func (b *Type) Close() { + b.dg.Close() +} + +func (b *Type) periodicTicker() { + onlineFriendships := time.NewTimer(tickerIntervalFriendshipOnline * time.Minute) + checkConversations := time.NewTimer(tickerIntervalCheckIncomingConversations * time.Minute) + checkIncomingFriendshipRequests := time.NewTimer(tickerIntervalCheckIncomingFriendshipRequest * time.Minute) + updateGuildEmojis := time.NewTimer(tickerIntervalUpdateGuildEmojis * time.Minute) + for { + select { + case <-onlineFriendships.C: + b.avr.FetchOnlineFriendships(b.userConnected, b.userDisconnected) + onlineFriendships.Reset(tickerIntervalFriendshipOnline * time.Minute) + case <-checkConversations.C: + log.Println("Checking for conversations") + pm, _ := b.avr.FetchPendingConversations() + if len(pm) > 0 { + b.handleNewPendingConversation(pm) + } + checkConversations.Reset(tickerIntervalCheckIncomingConversations * time.Minute) + case <-checkIncomingFriendshipRequests.C: + log.Println("Checking for incoming friendship request") + fr, _ := b.avr.FetchPendingFriendshipRequests() + if len(fr) > 0 { + b.handleNewFriendshipRequests(fr) + } + checkIncomingFriendshipRequests.Reset(tickerIntervalCheckIncomingFriendshipRequest * time.Minute) + case <-updateGuildEmojis.C: + log.Println("Updating guild emojis") + b.dg.Emojis, _ = b.dg.Session.GuildEmojis(b.DGsID) + updateGuildEmojis.Reset(tickerIntervalUpdateGuildEmojis * time.Minute) + } + } +} + +func (b *Type) userConnected(u altvr.User) { + s := fmt.Sprintf("%s**%s is now online in %s!**", b.getUserEmojiByAltVRUser(u), u.GetDisplayName(), u.CurrentSpace.Name) + if !b.isQuiet { + b.dg.Session.ChannelMessageSend(b.DGcID, s) + } +} + +func (b *Type) userDisconnected(u altvr.User) { + s := fmt.Sprintf("%s_%s is now offline!_", b.getUserEmojiByAltVRUser(u), u.GetDisplayName()) + if !b.isQuiet { + b.dg.Session.ChannelMessageSend(b.DGcID, s) + } +} + +func (b *Type) loadUserFile() error { + var err error + file, err := os.Open("users.json") + if err != nil { + return err + } + defer file.Close() + decoder := json.NewDecoder(file) + err = decoder.Decode(&b.users) + if err != nil { + return err + } + return nil +} + +func (b *Type) saveUserFile() error { + var err error + file, err := os.OpenFile("users.json", os.O_RDWR|os.O_CREATE, 0644) + if err != nil { + return err + } + defer file.Close() + + buffer := new(bytes.Buffer) + encoder := json.NewEncoder(file) + err = encoder.Encode(b.users) + if err != nil { + return err + } + _, err = file.Write(buffer.Bytes()) + if err != nil { + return err + } + return nil +} + +func (b *Type) handleNewFriendshipRequests(fr []altvr.Friendship) { + for _, rr := range fr { + found := false + for _, rrr := range b.frPending { + if rr.FriendshipID == rrr.Friendship.FriendshipID { + found = true + break + } + } + if !found { + u := b.avr.GetFriend(rr.UserID) + s := b.getReplyMessage("new_friendship_requested", u.GetDisplayName()) + if msg, err := b.dg.Session.ChannelMessageSend(b.DGcID, s); err == nil { + b.frPending = append(b.frPending, FriendshipRequestPending{ + Friendship: rr, + MessageID: msg.ID, + }) + } + } + } + // XXX: Remove withdrawn requests +} + +func (b *Type) handleNewPendingConversation(pm []altvr.Conversation) { + for _, mm := range pm { + au := b.avr.GetFriend(mm.SenderID) + uu, _ := b.getUserByAltVRUserID(mm.SenderID) + at := au.GetDisplayName() + if uu.DiscordID != "" { + at = b.buildMention(uu) + } + s := at + ": " + mm.Subject + if msg, err := b.dg.Session.ChannelMessageSend(b.DGcID, s); err == nil { + b.convos[msg.ID] = mm.ID + if err := b.avr.AcknowkledgeConversation(mm.ID); err != nil { + log.Printf("Error acknownledging conversation: %+v\n", err) + } + } + } +} + +func (b *Type) getUserEmojiByAltVRUser(au altvr.User) string { + for _, u := range b.users { + if u.AltVRUserID == au.UserID { + for k, e := range b.dg.Emojis { + if e.Name == u.DiscordEmoji.Name { + return fmt.Sprintf("<:%s:%s> ", b.dg.Emojis[k].Name, b.dg.Emojis[k].ID) + } + } + } + } + + return "" +} + +func (b *Type) getUserByAltVRUserID(avrID string) (User, error) { + for _, u := range b.users { + if u.AltVRUserID == avrID { + return u, nil + } + } + return User{}, errors.New("User not found") +} + +func (b *Type) getUserByDiscordID(discordID string) (User, error) { + for _, u := range b.users { + if u.DiscordID == discordID { + return u, nil + } + } + return User{}, errors.New("User not found") +} + +func (b *Type) getUserByDiscordName(discordName string) (User, error) { + for _, u := range b.users { + if u.DiscordName == discordName { + return u, nil + } + } + return User{}, errors.New("User not found") +} + +func (b *Type) buildMention(uu User) string { + var msg string + if uu.DiscordEmoji.Name != "" { + msg = fmt.Sprintf("<:%s:%s> ", uu.DiscordEmoji.Name, uu.DiscordEmoji.ID) + } + du, err := b.dg.Session.User(uu.DiscordID) + if err == nil { + msg = msg + du.Mention() + } + return msg +} diff --git a/pkg/bot/excuses.go b/pkg/bot/excuses.go new file mode 100644 index 0000000..35e7490 --- /dev/null +++ b/pkg/bot/excuses.go @@ -0,0 +1,544 @@ +package bot + +import ( + "math/rand" + "time" +) + +// BOFHExcuses is a list of excuses from BOFH. +// Source: http://pages.cs.wisc.edu/~ballard/bofh/excuses. +var BOFHExcuses []string = []string{ + "Clock speed.", + "Solar Flares.", + "Electromagnetic radiation from satellite debris.", + "Static from nylon underwear.", + "Static from plastic slide rules.", + "Global warming.", + "Poor power conditioning.", + "Static build-up.", + "Doppler effect.", + "Hardware stress fractures.", + "Magnetic interference from money/credit cards.", + "Dry joints on cable plug.", + "We're waiting for [the phone company] to fix that line.", + "Sounds like a Windows problem, try calling Microsoft support.", + "Temporary routing anomaly.", + "Somebody was calculating 'pi' on the server.", + "Fat electrons in the lines.", + "Excess surge protection.", + "Floating point processor overflow.", + "Divide-by-zero error.", + "POSIX compliance problem.", + "Monitor resolution too high.", + "Improperly oriented keyboard.", + "Network packets travelling up-hill (use a carrier pigeon).", + "Decreasing electron flux.", + "First Saturday after first full moon in winter.", + "Radiosity depletion.", + "CPU radiator broken.", + "It works the way the Wang did, what's the problem.", + "Positron router malfunction.", + "Cellular telephone interference.", + "Techtonic stress.", + "Piezoelectric interference.", + "(L)user error.", + "Working as designed.", + "Dynamic software linking table corrupted.", + "Heavy gravity fluctuation, move computer to floor rapidly!", + "Secretary plugged hairdryer into UPS.", + "Terrorist activities.", + "Not enough memory, go get system upgrade.", + "Interrupt configuration error.", + "Spaghetti cable cause packet failure.", + "Boss forgot system password.", + "Bank Holiday: System operating credits not recharged.", + "Virus attack, (l)user responsible.", + "Waste water tank overflowed onto computer.", + "Complete Transient Lockout.", + "Bad ether in the cables.", + "Bogon emissions.", + "Change in Earth's rotational speed.", + "Cosmic ray particles crashed through the hard disk platter.", + "Smell from unhygienic janitorial staff wrecked the tape heads.", + "Little hamster in running wheel had coronary; waiting for replacement " + + "to be Fedexed from Wyoming.", + "Evil dogs hypnotised the night shift.", + "Plumber mistook routing panel for decorative wall fixture.", + "Electricians made popcorn in the power supply.", + "Groundskeepers stole the root password.", + "High pressure system failure.", + "Failed trials, system needs redesign.", + "System has been recalled.", + "Not approved by the FCC.", + "Need to wrap system in aluminum foil to fix problem.", + "Not properly grounded, please bury computer.", + "CPU needs recalibration.", + "System needs to be rebooted.", + "Bit bucket overflow.", + "Descramble code needed from software company.", + "Only available on a need to know basis.", + "Knot in cables caused data stream to become twisted and kinked.", + "Nesting roaches shorted out the ether cable.", + "The file system is full of it.", + "Satan did it.", + "Daemons did it.", + "You're out of memory.", + "There isn't any problem.", + "Unoptimized hard drive.", + "Typo in the code.", + "Yes, yes, its called a design limitation.", + "Look, buddy: Windows 3.1 _is_ a General Protection Fault.", + "That's a great computer you have there; have you considered how it " + + "would work as a BSD machine?", + "Please excuse me, I have to circuit an AC line through my head to get " + + "this database working.", + "Yeah, yo mama dresses you funny and you need a mouse to delete files.", + "Support staff hung over, send aspirin and come back LATER.", + "Someone is standing on the ethernet cable, causing a kink in the cable.", + "Windows 95 undocumented 'feature'.", + "Runt packets.", + "Password is too complex to decrypt.", + "Boss' kid fucked up the machine.", + "Electromagnetic energy loss.", + "Budget cuts.", + "Mouse chewed through power cable.", + "Stale file handle (next time use Tupperware (TM)!).", + "Feature not yet implemented.", + "Internet outage.", + "Pentium FDIV bug.", + "Vendor no longer supports the product.", + "Small animal kamikaze attack on power supplies.", + "The vendor put the bug there.", + "SIMM crosstalk.", + "IRQ dropout.", + "Collapsed Backbone.", + "Power company testing new voltage spike (creation) equipment.", + "Operators on strike due to broken coffee machine.", + "Backup tape overwritten with copy of system manager's favourite CD.", + "UPS interrupted the server's power.", + "The electrician didn't know what the yellow cable was so he yanked " + + "the ethernet out.", + "The keyboard isn't plugged in.", + "The air conditioning water supply pipe ruptured over the machine room.", + "The electricity substation in the car park blew up.", + "The rolling stones concert down the road caused a brown out.", + "The salesman drove over the CPU board.", + "The monitor is plugged into the serial port.", + "Root nameservers are out of sync.", + "Electro-magnetic pulses from French above ground nuke testing.", + "Your keyboard's space bar is generating spurious keycodes.", + "The real TTYs became pseudo TTYs and vice-versa.", + "The printer thinks its a router.", + "The router thinks its a printer.", + "Evil hackers from Serbia.", + "We just switched to FDDI.", + "Halon system went off and killed the operators.", + "Because Bill Gates is a Jehovah's witness and so nothing can work " + + "on St. Swithin's day.", + "User to computer ratio too high.", + "User to computer ration too low.", + "We just switched to Sprint.", + "It has Intel Inside.", + "Sticky bits on disk.", + "Power company having EMP problems with their reactor.", + "The ring needs another token.", + "New management.", + "telnet: Unable to connect to remote host: Connection refused.", + "SCSI Chain overterminated.", + "It's not plugged in.", + "Because of network lag due to too many people playing deathmatch.", + "You put the disk in upside down.", + "Daemons loose in system.", + "User was distributing pornography on server; system seized by FBI.", + "BNC (Brain Not Connected)", + "UBNC (User Brain Not Connected)", + "LBNC ((L)user Brain Not Connected)", + "Disks spinning backwards - toggle the hemisphere jumper.", + "New guy cross-connected phone lines with AC power bus.", + "Had to use hammer to free stuck disk drive heads.", + "Too few computrons available.", + "Flat tire on station wagon with tapes. (\"Never underestimate the " + + "bandwidth of a station wagon full of tapes hurling down the " + + "highway\" - Andrew S. Tannenbaum)", + "Communications satellite used by the military for Star Wars.", + "Party-bug in the Aloha protocol.", + "Insert coin for new game.", + "Dew on the telephone lines.", + "Arcserve crashed the server again.", + "Some one needed the powerstrip, so they pulled the switch plug.", + "My pony-tail hit the on/off switch on the power strip.", + "Big to little endian conversion error.", + "You can tune a file system, but you can't tune a fish " + + "(from most `tunefs' man pages).", + "Dumb terminal.", + "Zombie processes haunting the computer.", + "Incorrect time synchronization.", + "Defunct processes.", + "Stubborn processes.", + "Non-redundant fan failure.", + "Monitor VLF leakage", + "Bugs in the RAID.", + "No 'any' key on keyboard.", + "Root Rot.", + "Backbone Scoliosis.", + "/pub/lunch", + "Excessive collisions and not enough packet ambulances.", + "le0: no carrier: transceiver cable problem?", + "Broadcast packets on wrong frequency.", + "Popper unable to process jumbo kernel", + "NOTICE: alloc: /dev/null: filesystem full", + "Pseudo-user on a pseudo-terminal.", + "Recursive traversal of loopback mount points.", + "Backbone adjustment.", + "OS swapped to disk.", + "Vapors from evaporating sticky-note adhesives.", + "Sticktion.", + "Short leg on process table.", + "Multicasts on broken packets.", + "Ether leak!", + "Atilla the Hub.", + "Endothermal recalibration.", + "Filesystem not big enough for Jumbo Kernel Patch.", + "Loop found in loop in redundant loopback.", + "System consumed all the paper for paging.", + "Permission denied.", + "Reformatting Page. Wait...", + "...disk or the processor is on fire.", + "SCSI's too wide.", + "Proprietary Information.", + "Just type 'mv * /dev/null'.", + "Runaway cat on system.", + "Did you pay the new Support Fee?", + "We only support a 1200 bps connection.", + "We only support a 28000 bps connection.", + "Me no Internet, only janitor, me just wax floors.", + "I'm sorry a Pentium won't do, you need an SGI to connect with us.", + "Post-it Note sludge leaked into the monitor.", + "The curls in your keyboard cord are losing electricity.", + "The monitor needs another box of pixels.", + "RPC_PMAP_FAILURE", + "kernel panic: write-only-memory (/dev/wom0) capacity exceeded.", + "Write-only-memory subsystem too slow for this machine. " + + "Contact your local dealer.", + "Just pick up the phone and give modem connect sounds. " + + "\"Well you said we should get more lines so we don't " + + "have voice lines.\"", + "Quantum dynamics are affecting the transistors.", + "Police are examining all the Internet packets in the search " + + "for a narco-net-trafficker.", + "We are currently trying a new concept of using a live mouse. " + + "Unfortunately, one has yet to survive being hooked up to the " + + "computer... Please bear with us.", + "Your mail is being routed through Germany... and they're censoring us.", + "Only people with names beginning with 'A' are getting mail this " + + "week (a la Microsoft).", + "We didn't pay the Internet bill and it's been cut off.", + "Lightning strikes.", + "Of course it doesn't work. We've performed a software upgrade.", + "Change your language to Finnish.", + "Fluorescent lights are generating negative ions. If turning them off " + + "doesn't work, take them out and put tin foil on the ends.", + "High nuclear activity in your area.", + "What office are you in? Oh, that one. Did you know that your building " + + "was built over the universities first nuclear research site? " + + "And wow, aren't you the lucky one? Your office is right over " + + "where the core is buried!", + "The MGs ran out of gas.", + "The UPS doesn't have a battery backup.", + "Recursivity. Call back if it happens again.", + "Someone thought The Big Red Button was a light switch.", + "The mainframe needs to rest. It's getting old, you know.", + "I'm not sure. Try calling the Internet's head " + + "office -- it's in the book.", + "The lines are all busy (busied out, that is -- why let " + + "them in to begin with?).", + "Jan 9 16:41:27 huber su: 'su root' succeeded for [...] on /dev/pts/1", + "It's those computer people in X {city of world}. " + + "They keep stuffing things up.", + "A Star Wars satellite accidently blew up the WAN.", + "Fatal error right in front of screen.", + "That function is not currently supported, but Bill Gates assures " + + "us it will be featured in the next upgrade.", + "Wrong polarity of neutron flow.", + "(L)users learning curve appears to be fractal.", + "We had to turn off that service to comply with the CDA Bill.", + "Ionization from the air-conditioning.", + "TCP/IP UDP alarm threshold is set too low.", + "Someone is broadcasting pygmy packets and the router doesn't " + + "know how to deal with them.", + "The new frame relay network hasn't bedded down the software " + + "loop transmitter yet.", + "Fanout dropping voltage too much, try cutting some of those " + + "little traces.", + "Plate voltage too low on demodulator tube.", + "You did wha... Oh _dear_ ...", + "CPU needs bearings repacked.", + "Too many little pins on CPU confusing it, bend back and forth until " + + "10-20% are neatly removed. Do _not_ leave metal bits visible!", + "_Rosin_ core solder? But...", + "Software uses U.S. measurements, but the OS is in metric...", + "The computer fleetly, mouse and all.", + "Your cat tried to eat the mouse.", + "The Borg tried to assimilate your system. Resistance is futile.", + "It must have been the lightning storm we had " + + "yesterday/last week/last month.", + "Due to Federal Budget problems we have been forced to cut back " + + "on the number of users able to access the system at one time. " + + "(namely none allowed...)", + "Too much radiation coming from the soil.", + "Unfortunately we have run out of bits/bytes/whatever. " + + "Don't worry, the next supply will be coming next week.", + "Program load too heavy for processor to lift.", + "Processes running slowly due to weak power supply.", + "Our ISP is having {switching,routing,SMDS,frame relay} problems.", + "We've run out of licenses.", + "Interference from lunar radiation.", + "Standing room only on the bus.", + "You need to install an RTFM interface.", + "That would be because the software doesn't work.", + "That's easy to fix, but I can't be bothered.", + "Someone's tie is caught in the printer, and if anything " + + "else gets printed, he'll be in it too.", + "We're upgrading /dev/null.", + "The Usenet news is out of date.", + "Our POP server was kidnapped by a weasel.", + "It's stuck in the Web.", + "Your modem doesn't speak English.", + "The mouse escaped.", + "All of the packets are empty.", + "The UPS is on strike.", + "Neutrino overload on the nameserver.", + "Melting hard drives.", + "Someone has messed up the kernel pointers.", + "The kernel license has expired.", + "Netscape has crashed.", + "The cord jumped over and hit the power switch.", + "It was OK before you touched it.", + "Bit Rot.", + "Your Flux Capacitor has gone bad.", + "The Dilithium Crystals need to be rotated.", + "The static electricity routing is acting up...", + "Traceroute says that there is a routing problem in the backbone. " + + "It's not our problem.", + "The co-locator cannot verify the frame-relay gateway to the ISDN server.", + "High altitude condensation from U.S.A.F. prototype aircraft " + + "has contaminated the primary subnet mask. Turn off your computer " + + "for 9 days to avoid damaging it.", + "Lawn mower blade in your fan need sharpening.", + "Electrons on a bender.", + "Telecommunications is upgrading.", + "Telecommunications is downgrading.", + "Telecommunications is downshifting.", + "Hard drive sleeping. Let it wake up on it's own...", + "Interference between the keyboard and the chair.", + "The CPU has shifted, and become decentralized.", + "Due to the CDA, we no longer have a root account.", + "We ran out of dial tone and we're and waiting for the phone company " + + "to deliver another bottle.", + "You must've hit the wrong 'any' key.", + "PCMCIA slave driver.", + "The token fell out of the ring. Call us when you find it.", + "The hardware bus needs a new token.", + "Too many interrupts.", + "Not enough interrupts.", + "The data on your hard drive is out of balance.", + "Digital Manipulator exceeding velocity parameters", + "Appears to be a slow/narrow SCSI-0 Interface problem", + "Micro-electronic Riemannian curved-space fault in " + + "write-only file system.", + "Fractal radiation jamming the backbone.", + "Routing problems on the neural net.", + "IRQ-problems with the Un-Interruptible-Power-Supply.", + "CPU-angle has to be adjusted because of vibrations coming " + + "from the nearby road.", + "Emissions from GSM-phones", + "CD-ROM server needs recalibration.", + "Firewall needs cooling.", + "Asynchronous inode failure.", + "Transient bus protocol violation.", + "Incompatible bit-registration operators.", + "Your process is not ISO 9000 compliant.", + "You need to upgrade your VESA local bus to a MasterCard local bus.", + "The recent proliferation of nuclear testing.", + "Elves on strike. (Why do they call EMAG Elf Magic?)", + "Internet exceeded (L)user level, please wait until a (l)user logs " + + "off before attempting to log back on.", + "Your e-mail is now being delivered by the USPS.", + "Your computer hasn't been returning all the bits it gets " + + "from the Internet.", + "You've been infected by the Telescoping Hubble virus.", + "Scheduled global CPU outage.", + "Your Pentium has a heating problem - try cooling it with " + + "ice cold water. (Do not turn off your computer, you do not " + + "want to cool down the Pentium Chip while he isn't working, do you?)", + "Your processor has processed too many instructions. " + + "Turn it off immediately, do not type any commands!", + "Your packets were eaten by the Terminator.", + "Your processor does not develop enough heat.", + "We need a licensed electrician to replace the light " + + "bulbs in the computer room.", + "The POP server is out of Coke.", + "Fiber optics caused gas main leak.", + "Server depressed, needs Prozac.", + "Quantum Decoherence.", + "Those damn raccoons!", + "Suboptimal routing experience.", + "A plumber is needed, the network drain is clogged.", + "50% of the manual is in '.pdf' README files.", + "The AA battery in the wallclock sends magnetic interference.", + "The XY axis in the trackball is coordinated with the summer solstice.", + "The butane lighter causes the pin-cushioning.", + "Old inkjet cartridges emanate barium-based fumes.", + "Manager in the cable duct.", + "We'll fix that in the next (upgrade/update/patch release/service pack).", + "HTTPD Error 666: BOFH was here.", + "HTTPD Error 4004: Very old Intel CPU - insufficient processing power.", + "The ATM board has run out of 10 pound notes. We are having a " + + "whip round to refill it, care to contribute?", + "Network Failure - call NBC.", + "Having to manually track the satellite.", + "Your/our computer(s) had suffered a memory leak, and we are " + + "waiting for them to be topped up.", + "The rubber band broke.", + "We're on Token Ring, and it looks like the token got loose.", + "Stray alpha particles from memory packaging caused hard " + + "memory error on server.", + "Paradigm shift... without a clutch.", + "PEBKAC (Problem Exists Between Keyboard And Chair)", + "The cables are not the same length.", + "Second-system effect.", + "Chewing gum on /dev/sd3c.", + "Boredom in the kernel.", + "The daemons! The daemons! The terrible daemons!", + "I'd love to help you -- it's just that the boss won't let " + + "me near the computer.", + "Struck by the Good Times virus", + "YOU HAVE AN I/O ERROR: Incompetent Operator error.", + "Your parity check is overdrawn and you're out of cache.", + "Communist revolutionaries taking over the server room and demanding " + + "all the computers in the building or they shoot the sys-admin. " + + "Poor misguided fools.", + "Plasma conduit breach.", + "Out of cards on drive 'D:'.", + "Sand fleas eating the Internet cables.", + "Parallel processors running perpendicular today.", + "ATM cell has no roaming feature turned on, notebooks can't connect.", + "Webmasters kidnapped by evil cult.", + "Failure to adjust for daylight savings time.", + "Virus transmitted from computer to sysadmins.", + "Virus due to computers having unsafe sex.", + "Incorrectly configured static routes on the core-routers.", + "Forced to support NT servers; sys-admins quit.", + "Suspicious pointer corrupted virtual machine.", + "It's the InterNIC's fault.", + "Root name servers corrupted.", + "Budget cuts forced us to sell all the power cords for the servers.", + "Someone hooked the twisted pair wires into the answering machine.", + "Operators killed by year 2000 bug bite.", + "We've picked COBOL as the language of choice.", + "Operators killed when huge stack of backup tapes fell over.", + "Robotic tape changer mistook operator's tie for a backup tape.", + "Someone was smoking in the computer room and set off the halon systems.", + "Your processor has taken a ride to Heaven's Gate on the UFO " + + "behind Hale-Bopp's comet.", + "It's an ID-10-T error.", + "Dyslexics retyping hosts file on servers.", + "The Internet is being scanned for viruses.", + "Your computer's union contract is set to expire at midnight.", + "Bad user karma.", + "/dev/clue was linked to /dev/null", + "Increased sunspot activity.", + "We already sent around a notice about that.", + "It's union rules. There's nothing we can do about it. Sorry.", + "Interference from the Van Allen Belt.", + "Jupiter is aligned with Mars.", + "Redundant ACLs.", + "Mail server hit by UniSpammer.", + "T-1's congested due to porn traffic to the news server.", + "Data for intranet got routed through the extranet and " + + "landed on the Internet.", + "We are a 100% Microsoft Shop.", + "We are Microsoft. What you are experiencing is not a problem; " + + "it is an undocumented feature.", + "Sales staff sold a product we don't offer.", + "Secretary sent chain letter to all 5000 employees.", + "Sysadmin didn't hear pager go off due to loud music from " + + "bar-room speakers.", + "Sysadmin accidentally destroyed pager with a large hammer.", + "Sysadmins unavailable because they are in a meeting talking " + + "about why they are unavailable so much.", + "Bad cafeteria food landed all the sysadmins in the hospital.", + "Route flapping at the NAP.", + "Computers under water due to SYN flooding.", + "The vulcan-death-grip ping has been applied.", + "Electrical conduits in machine room are melting.", + "Traffic jam on the Information Superhighway.", + "Radial Telemetry Infiltration.", + "Cow-tippers tipped a cow onto the server.", + "Tachyon emissions overloading the system.", + "Maintenance window broken.", + "We're out of slots on the server.", + "Computer room being moved. Our systems are down for the weekend.", + "Sysadmins busy fighting spam.", + "Repeated reboots of the system failed to solve problem.", + "Feature was not beta tested.", + "Domain controller not responding.", + "Someone else stole your IP address, call the Internet detectives!", + "It's not RFC-822 compliant.", + "Operation failed because there is no message for this error (#1014).", + "Stop bit received", + "The Internet is needed to catch the etherbunny.", + "Network down, IP packets delivered via UPS.", + "Firmware update in the coffee machine.", + "Temporal anomaly.", + "Mouse has out-of-cheese-error.", + "Borg implants are failing.", + "Borg nanites have infested the server.", + "error: one bad user found in front of screen", + "Please state the nature of the technical emergency", + "Internet shut down due to maintenance.", + "Daemon escaped from pentagram.", + "Crop circles in the corn shell.", + "Sticky bit has come loose.", + "Hot Java has gone cold.", + "Cache miss - please take better aim next time.", + "Hash table has woodworm.", + "Trojan horse ran out of hay.", + "Zombie processes detected, machine is haunted.", + "Overflow error in /dev/null.", + "Browser's cookie is corrupted -- someone's been nibbling on it.", + "Mailer-daemon is busy burning your message in hell.", + "According to Microsoft, it's by design.", + "'vi' needs to be upgraded to 'vii'.", + "Greenpeace free()'d the mallocs", + "Terrorists crashed an airplane into the server room, " + + "have to remove /bin/laden. (rm -rf /bin/laden).", + "Astropneumatic oscillations in the water-cooling.", + "Somebody ran the operating system through a spelling checker.", + "Rhythmic variations in the voltage reaching the power supply.", + "Keyboard Actuator Failure. Order and Replace.", + "Packet held up at customs.", + "Propagation delay.", + "High line impedance.", + "Someone set us up the bomb.", + "Power surges on the Underground.", + "Don't worry; it's been deprecated. The new one is worse.", + "Excess condensation in cloud network.", + "It is a layer 8 problem.", + "The math co-processor had an overflow error that leaked out and " + + "shorted the RAM.", + "Leap second overloaded RHEL6 servers.", + "DNS server drank too much and had a hiccup.", + "Your machine had the fuses in backwards.", +} + +// getExcuse gets a random excuse from a list of excuses and writes it out +func getBOFHExcuse() string { + return BOFHExcuses[randIdx(len(BOFHExcuses)-1)] +} + +// randIdx returns a random number within the specified range. +func randIdx(n int) uint { + rand.Seed(time.Now().UnixNano()) + return uint(rand.Intn(n)) +} diff --git a/pkg/bot/handlers.go b/pkg/bot/handlers.go new file mode 100644 index 0000000..09f0e79 --- /dev/null +++ b/pkg/bot/handlers.go @@ -0,0 +1,517 @@ +package bot + +// FIXME: aemoji does not update existing users + +import ( + "fmt" + "log" + "regexp" + "strings" + + "git.lalonde.me/matth/AltVRBot/pkg/discord/mux" + "github.com/bwmarrin/discordgo" +) + +var ( + commandFormats = map[string]string{ + "auser": " [Discord Emoji]", + "aemoji": " ", + "msg": " ...", + "accept": "(As reply) [Discord Emoji]", + "deny": "(As reply)", + } + + commandReplies = map[string]string{ + "online_welcome": "Hello, AltVRBot online and here to serve!", + "all_done": "All done!", + "unauthorized_user": "Sorry, you are not authorized to do this!", + "invalid_command": "Invalid command format!", + "unknown_error": "I couldn't not process your request due to an unknown error!", + "auser_unknown_friend": "I don't know about this user, are you sure they are one of my friends and the username is correct?", + "auser_known_user": "I already know about this user!", + "aemoji_uknow_user": "I don't know about this user, try to associate it first!", + "msg_unknown_sender": "I don't know you! You must be associated first!", + "msg_unknown_receipient": "I don't know about <@!%s>! They must be associated first!", + "msg_all_done_truncated": "All done, however your message was too long and truncated!", + "new_friendship_requested": "I just heard that %s would like to become friends, should I accept?", + "accept_unknown_reference": "Unknown reference, are you sure you are replying to the right message? Perhaps the request was withdrawn!", + } + + commandRepliesRude = map[string]string{ + "online_welcome": "Ah fuck, back to work already? Well message me, maybe I'll help you anyway!", + "all_done": "Alright alright, stop bugging me already, I got it done for you, you fuck!", + "unauthorized_user": "Fartface, who the fuck do you think you are?!? You're not allowed to do that you lowlife!", + "invalid_command": "You drunk or something?", + "unknown_error": "_BOFH_", + "auser_unknown_friend": "You dimwit, you should know I don't know who this idiot is. Do I really want them to be my friend? I don't know, but maybe they do!", + "auser_known_user": "Fuckwad, I already know about this asshole!", + "aemoji_uknow_user": "Who the fuck is that? Maybe try to tell me first you airhead!", + "msg_unknown_sender": "Never heard of you chucklefuck! You even registered?", + "msg_unknown_receipient": "I don't know about this chickenfucker named <@!%s>! They must be associated first!", + "msg_all_done_truncated": "Ok ok I did it you shitstick, but you're a verbose fuck so I had to cut your message off a bit!", + "new_friendship_requested": "Have you heard about this dipshit named %s, they think they're cool enough, ha! Should we let that numskull in?", + "accept_unknown_reference": "Numnuts, I don't know what you're talking about! That user might have been too cool for you, or you're just fucking confused!", + } +) + +func (b *Type) loadDiscordHandlers() { + // XXX: Status [Discord Mention] reload the online users states and display + b.dg.Router.Route("auser", "Associates an AltVR user to a discord user", b.handleAssociateUser) + b.dg.Router.Route("aemoji", "Associates or updates an AltVR user to a discord guild emoji", b.handleAssociateEmoji) + b.dg.Router.Route("accept", "Accept a pending friendship request", b.handleAcceptFriendship) + b.dg.Router.Route("deny", "Deny a pending friendship request", b.handleDenyFriendship) + // XXX: Remove + b.dg.Router.Route("reload", "Reload the bot's data and configs", b.handleReload) + b.dg.Router.Route("check", "Force a recheck of pending friendship requests and messsages", b.handleForceCheck) + b.dg.Router.Route("msg", "Message an AltVR user", b.handleSendMessage) + // XXX: Promote + b.dg.Session.AddHandler(b.handleMessageReplies) +} + +func (b *Type) handleAssociateUser(ds *discordgo.Session, dm *discordgo.Message, ctx *mux.Context) { + //fmt.Printf("dm:\t%+v\nctv:\t%+v\n", dm, ctx) + if !b.checkUserRole(dm.Author.ID, RoleModerator) && len(b.users) != 0 { + b.replyPermissionDenied(ds, dm, ctx) + return + } + c := strings.TrimSpace(ctx.Content) + p := strings.Split(c, " ") + if len(p) < 3 || len(p) > 4 { + b.replyInvalidCommandFormat(ds, dm, ctx, "auser") + return + } + // XXX + discordID := strings.Trim(strings.Replace(p[2], "@!", "", -1), "<>") + friend := b.avr.GetFriendByUsername(p[1]) + if friend.Username == "" { + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, + b.getReplyMessage("auser_unknown_friend"), + &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) + return + } + u, _ := b.getUserByDiscordID(discordID) + if u.AltVRUserID != "" { + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, + b.getReplyMessage("auser_known_user"), + &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) + return + } + e := UserEmoji{} + if len(p) == 4 { + s := strings.Split(strings.Trim(p[3], "<>"), ":") + e.ID = s[2] + e.Name = s[1] + } + role := RoleUser + if len(b.users) == 0 { + role = RoleAdmin + } + b.users = append(b.users, User{ + AltVRUserID: friend.UserID, + DiscordID: discordID, + DiscordName: dm.Mentions[len(dm.Mentions)-1].Username, + DiscordEmoji: e, + Role: role, + isRude: false, + }) + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, + b.getReplyMessage("all_done"), + &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) + if err := b.saveUserFile(); err != nil { + log.Printf("Error while saving user file: %+v\n", err) + } +} + +func (b *Type) handleAssociateEmoji(ds *discordgo.Session, dm *discordgo.Message, ctx *mux.Context) { + //fmt.Printf("dm:\t%+v\nctv:\t%+v\n", dm, ctx) + if !b.checkUserRole(dm.Author.ID, RoleModerator) { + b.replyPermissionDenied(ds, dm, ctx) + return + } + c := strings.TrimSpace(ctx.Content) + p := strings.Split(c, " ") + if len(p) != 3 { + b.replyInvalidCommandFormat(ds, dm, ctx, "aemoji") + return + } + // XXX + discordID := strings.Trim(strings.Replace(p[1], "@!", "", -1), "<>") + u, err := b.getUserByDiscordID(discordID) + if err != nil { + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, + b.getReplyMessage("aemoji_unknown_user"), + &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) + return + } + e := UserEmoji{} + if len(p) == 4 { + s := strings.Split(strings.Trim(p[2], "<>"), ":") + e.ID = s[2] + e.Name = s[1] + } + u.DiscordEmoji = e + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, + b.getReplyMessage("all_done"), + &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) + if err := b.saveUserFile(); err != nil { + log.Printf("Error while saving user file: %+v\n", err) + } +} + +// XXX Don't allow messaging the bot +func (b *Type) handleSendMessage(ds *discordgo.Session, dm *discordgo.Message, ctx *mux.Context) { + //fmt.Printf("dm:\t%+v\nctv:\t%+v\n", dm, ctx) + if dm.MessageReference != nil { + return + } + u, err := b.getUserByDiscordID(dm.Author.ID) + if err != nil { + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, + b.getReplyMessage("msg_unknown_sender"), + &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) + return + } + um := b.getMentions(dm.Mentions) + if len(um) != 1 { + b.replyInvalidCommandFormat(ds, dm, ctx, "msg") + return + } + discordID := um[0].ID + uu, err := b.getUserByDiscordID(discordID) + if err != nil { + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, + b.getReplyMessage("msg_unknown_receipient", discordID), + &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) + return + } + reg := regexp.MustCompile(fmt.Sprintf(" <@!?(%s)>", discordID)) + c := strings.TrimSpace(ctx.Content) + c = reg.ReplaceAllString(c, "") + c = strings.TrimPrefix(c, "msg ") + + au := b.avr.GetFriend(u.AltVRUserID) + c = au.GetDisplayName() + ": " + c + cl := len([]rune(c)) + c = truncateString(c, 140) + if err := b.avr.PostNewConversation(uu.AltVRUserID, c); err != nil { + log.Printf("Error while sending message: %+v\n", err) + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, + b.getReplyMessage("unknown_error"), + &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) + } else { + rm := "all_done" + if cl > len([]rune(c)) { + rm = "msg_all_done_truncated" + } + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, + b.getReplyMessage(rm), + &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) + } +} + +func (b *Type) handleMessageReplies(ds *discordgo.Session, mc *discordgo.MessageCreate) { + // Ignore all messages created by the Bot account itself + if mc.Author.ID == ds.State.User.ID { + return + } + if mc.MessageReference == nil { + return + } + if _, ok := b.convos[mc.MessageReference.MessageID]; !ok { + return + } + u, err := b.getUserByDiscordID(mc.Author.ID) + if err != nil { + b.dg.Session.ChannelMessageSendReply(mc.ChannelID, + b.getReplyMessage("msg_unknown_sender"), + &discordgo.MessageReference{ + MessageID: mc.ID, + ChannelID: mc.ChannelID, + GuildID: mc.GuildID, + }) + return + } + + au := b.avr.GetFriend(u.AltVRUserID) + msg := au.GetDisplayName() + ": " + mc.Content + cl := len([]rune(msg)) + msg = truncateString(msg, 140) + if err := b.avr.PostNewConversation(b.convos[mc.MessageReference.MessageID], msg); err != nil { + log.Printf("Error while replying to message: %+v\n", err) + b.dg.Session.ChannelMessageSendReply(mc.ChannelID, + b.getReplyMessage("unknown_error"), + &discordgo.MessageReference{ + MessageID: mc.ID, + ChannelID: mc.ChannelID, + GuildID: mc.GuildID, + }) + } else { + rm := "all_done" + if cl > len([]rune(msg)) { + rm = "msg_all_done_truncated" + } + b.dg.Session.ChannelMessageSendReply(mc.ChannelID, + b.getReplyMessage(rm), + &discordgo.MessageReference{ + MessageID: mc.ID, + ChannelID: mc.ChannelID, + GuildID: mc.GuildID, + }) + } +} + +func (b *Type) handleAcceptFriendship(ds *discordgo.Session, dm *discordgo.Message, ctx *mux.Context) { + //fmt.Printf("dm:\t%+v\nctv:\t%+v\n", dm, ctx) + if !b.checkUserRole(dm.Author.ID, RoleModerator) && len(b.users) != 0 { + b.replyPermissionDenied(ds, dm, ctx) + return + } + if dm.MessageReference == nil { + b.replyInvalidCommandFormat(ds, dm, ctx, "accept") + return + } + var req FriendshipRequestPending + found := false + for _, fr := range b.frPending { + if fr.MessageID == dm.MessageReference.MessageID { + found = true + req = fr + break + } + } + if !found { + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, + b.getReplyMessage("accept_unknown_reference"), + &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) + return + } + um := b.getMentions(dm.Mentions) + if len(um) != 1 { + b.replyInvalidCommandFormat(ds, dm, ctx, "accept") + return + } + reg := regexp.MustCompile(fmt.Sprintf(" <@!?(%s)>", um[0].ID)) + c := strings.TrimSpace(ctx.Content) + c = reg.ReplaceAllString(c, "") + p := strings.Split(c, " ") + if len(p) > 3 { + b.replyInvalidCommandFormat(ds, dm, ctx, "accept") + return + } + e := UserEmoji{} + if len(p) == 3 { + s := strings.Split(strings.Trim(p[2], "<>"), ":") + e.ID = s[2] + e.Name = s[1] + } + role := RoleUser + if len(b.users) == 0 { + role = RoleAdmin + } + b.users = append(b.users, User{ + AltVRUserID: req.Friendship.UserID, + DiscordID: um[0].ID, + DiscordName: dm.Mentions[len(dm.Mentions)-1].Username, + DiscordEmoji: e, + Role: role, + isRude: false, + }) + b.avr.AcceptFriendshipRequest(req.Friendship.FriendshipID) + for k, fr := range b.frPending { + if fr.Friendship.FriendshipID == req.Friendship.FriendshipID { + b.frPending = append(b.frPending[:k], b.frPending[k+1:]...) + break + } + } + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, + b.getReplyMessage("all_done"), + &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) + if err := b.saveUserFile(); err != nil { + log.Printf("Error while saving user file: %+v\n", err) + } + b.avr.FetchFriend(req.Friendship.UserID) +} + +func (b *Type) handleDenyFriendship(ds *discordgo.Session, dm *discordgo.Message, ctx *mux.Context) { + //fmt.Printf("dm:\t%+v\nctv:\t%+v\n", dm, ctx) + if !b.checkUserRole(dm.Author.ID, RoleModerator) { + b.replyPermissionDenied(ds, dm, ctx) + return + } + if dm.MessageReference == nil { + b.replyInvalidCommandFormat(ds, dm, ctx, "deny") + return + } + var req FriendshipRequestPending + found := false + for _, fr := range b.frPending { + if fr.MessageID == dm.MessageReference.MessageID { + found = true + break + } + } + if !found { + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, + b.getReplyMessage("accept_unknown_reference"), + &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) + return + } + b.avr.DenyFriendshipRequest(req.Friendship.FriendshipID) + for k, fr := range b.frPending { + if fr.Friendship.FriendshipID == req.Friendship.FriendshipID { + b.frPending = append(b.frPending[:k], b.frPending[k+1:]...) + break + } + } + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, + b.getReplyMessage("all_done"), + &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) +} + +func (b *Type) handleForceCheck(ds *discordgo.Session, dm *discordgo.Message, ctx *mux.Context) { + //fmt.Printf("dm:\t%+v\nctv:\t%+v\n", dm, ctx) + if !b.checkUserRole(dm.Author.ID, RoleModerator) { + b.replyPermissionDenied(ds, dm, ctx) + return + } + log.Println("Checking for incoming friendship request") + fr, _ := b.avr.FetchPendingFriendshipRequests() + if len(fr) > 0 { + b.handleNewFriendshipRequests(fr) + } + log.Println("Checking for conversations") + pm, _ := b.avr.FetchPendingConversations() + if len(pm) > 0 { + b.handleNewPendingConversation(pm) + } +} + +func (b *Type) handleReload(ds *discordgo.Session, dm *discordgo.Message, ctx *mux.Context) { + //fmt.Printf("dm:\t%+v\nctv:\t%+v\n", dm, ctx) + if !b.checkUserRole(dm.Author.ID, RoleAdmin) { + b.replyPermissionDenied(ds, dm, ctx) + return + } + log.Println("Reloading bot data") + b.loadUserFile() + err := b.avr.FetchMyUser() + if err != nil { + log.Printf("Error while reloading own user data: %+v\n", err) + } + b.dg.UpdateAvatar(b.avr.GetAvatarURL()) +} + +func (b *Type) checkUserRole(discordID string, role Roles) bool { + u, err := b.getUserByDiscordID(discordID) + if err != nil { + log.Printf("Error fetching bot user: %s\n", err) + return false + } + return (u.Role >= role) +} + +func (b *Type) replyPermissionDenied(ds *discordgo.Session, dm *discordgo.Message, ctx *mux.Context) { + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, b.getReplyMessage("unauthorized_user"), &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) +} + +func (b *Type) replyInvalidCommandFormat(ds *discordgo.Session, dm *discordgo.Message, ctx *mux.Context, cmd string) { + msg := b.getReplyMessage("invalid_command") + if usage, ok := commandFormats[cmd]; ok { + msg = msg + " " + usage + } + b.dg.Session.ChannelMessageSendReply(dm.ChannelID, msg, &discordgo.MessageReference{ + MessageID: dm.ID, + ChannelID: dm.ChannelID, + GuildID: dm.GuildID, + }) +} + +func (b *Type) getReplyMessage(msgID string, params ...interface{}) string { + if b.isRude { + if val, ok := commandRepliesRude[msgID]; ok { + if val == "_BOFH_" { + val = getBOFHExcuse() + } + + return fmt.Sprintf(val, params...) + } + } + if val, ok := commandReplies[msgID]; ok { + return fmt.Sprintf(val, params...) + } + return "Unknown message???" +} + +func (b *Type) getMentions(ms []*discordgo.User) []*discordgo.User { + var um []*discordgo.User + for _, u := range ms { + if u.ID != b.dg.User.ID { + um = append(um, u) + } + } + return um +} + +func truncateString(s string, i int) string { + runes := []rune(s) + if len(runes) > i { + return string(runes[:i]) + } + return s +} diff --git a/pkg/discord/discord.go b/pkg/discord/discord.go new file mode 100644 index 0000000..31af906 --- /dev/null +++ b/pkg/discord/discord.go @@ -0,0 +1,110 @@ +package discord + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + + "git.lalonde.me/matth/AltVRBot/pkg/discord/mux" + "github.com/bwmarrin/discordgo" +) + +// Discord bot +type Discord struct { + token string + sID string + cID string + avatarHash []byte + Emojis []*discordgo.Emoji + Session *discordgo.Session + Router *mux.Mux + User *discordgo.User +} + +// New instanciates a new discord bot +func New(token, sID, cID string) (*Discord, error) { + dg := &Discord{ + Router: mux.New(), + token: token, + sID: sID, + cID: cID, + } + + // Create a new Discord session using the provided bot token. + session, err := discordgo.New("Bot " + token) + if err != nil { + return dg, fmt.Errorf("Failed to create Discord session: %s", err) + } + + // Verify a Token was provided + if session.Token == "" { + return dg, errors.New("You must provide a Discord authentication token") + } + + dg.Session = session + + dg.addHandlers() + + // Open a websocket connection to Discord + err = dg.Session.Open() + if err != nil { + log.Printf("error opening connection to Discord, %s\n", err) + os.Exit(1) + } + + dg.User, _ = dg.Session.User("@me") + dg.Emojis, _ = dg.Session.GuildEmojis(sID) + + return dg, nil +} + +// Close terminates the discord session +func (dg *Discord) Close() { + dg.Session.Close() +} + +// UpdateAvatar Updates the bot user avatar if it has changed +func (dg *Discord) UpdateAvatar(url string) error { + if url == "" { + return nil + } + + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("Error retrieving the file, %s", err) + } + defer resp.Body.Close() + + img, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("Error reading the response, %s", err) + } + + h := sha256.Sum256(img) + + if bytes.Compare(h[:], dg.avatarHash) != 0 { + base64img := base64.StdEncoding.EncodeToString(img) + contentType := http.DetectContentType(img) + if base64img != "" { + avatar := fmt.Sprintf("data:%s;base64,%s", contentType, base64img) + _, err = dg.Session.UserUpdate("", "", "", avatar, "") + if err != nil { + return err + } + } + } + return nil +} + +func (dg *Discord) addHandlers() { + dg.Session.AddHandler(dg.Router.OnMessageCreate) + + dg.Router.Route("help", "Display this message.", dg.Router.Help) + //dg.router.Route("associate", "Associates an AltVR user to a discord user") +} diff --git a/pkg/discord/mux/help.go b/pkg/discord/mux/help.go new file mode 100644 index 0000000..575c05e --- /dev/null +++ b/pkg/discord/mux/help.go @@ -0,0 +1,81 @@ +package mux + +import ( + "fmt" + "sort" + "strconv" + + "github.com/bwmarrin/discordgo" +) + +// Help function provides a build in "help" command that will display a list +// of all registered routes (commands). To use this function it must first be +// registered with the Mux.Route function. +func (m *Mux) Help(ds *discordgo.Session, dm *discordgo.Message, ctx *Context) { + + // Set command prefix to display. + cp := "" + if ctx.IsPrivate { + cp = "" + } else if ctx.HasPrefix { + cp = m.Prefix + } else { + cp = fmt.Sprintf("@%s ", ds.State.User.Username) + } + + // Sort commands + maxlen := 0 + keys := make([]string, 0, len(m.Routes)) + cmdmap := make(map[string]*Route) + + for _, v := range m.Routes { + + // Only display commands with a description + if v.Description == "" { + continue + } + + // Calculate the max length of command+args string + l := len(v.Pattern) // TODO: Add the +args part :) + if l > maxlen { + maxlen = l + } + + cmdmap[v.Pattern] = v + + // help and about are added separately below. + if v.Pattern == "help" || v.Pattern == "about" { + continue + } + + keys = append(keys, v.Pattern) + } + + sort.Strings(keys) + + // TODO: Learn more link needs to be configurable + resp := "\n" //*Commands can be abbreviated and mixed with other text. Learn more at *\n" + resp += "```autoit\n" + + v, ok := cmdmap["help"] + if ok { + keys = append([]string{v.Pattern}, keys...) + } + + v, ok = cmdmap["about"] + if ok { + keys = append([]string{v.Pattern}, keys...) + } + + // Add sorted result to help msg + for _, k := range keys { + v := cmdmap[k] + resp += fmt.Sprintf("%s%-"+strconv.Itoa(maxlen)+"s # %s\n", cp, v.Pattern+v.Help, v.Description) + } + + resp += "```\n" + + ds.ChannelMessageSend(dm.ChannelID, resp) + + return +} diff --git a/pkg/discord/mux/mux.go b/pkg/discord/mux/mux.go new file mode 100644 index 0000000..c8626b0 --- /dev/null +++ b/pkg/discord/mux/mux.go @@ -0,0 +1,195 @@ +// Package mux provides a simple Discord message route multiplexer that +// parses messages and then executes a matching registered handler, if found. +// mux can be used with both Disgord and the DiscordGo library. +package mux + +import ( + "fmt" + "log" + "regexp" + "strings" + + "github.com/bwmarrin/discordgo" +) + +// Route holds information about a specific message route handler +type Route struct { + Pattern string // match pattern that should trigger this route handler + Description string // short description of this route + Help string // detailed help string for this route + Run HandlerFunc // route handler function to call +} + +// Context holds a bit of extra data we pass along to route handlers +// This way processing some of this only needs to happen once. +type Context struct { + Fields []string + Content string + IsDirected bool + IsPrivate bool + HasPrefix bool + HasMention bool + HasMentionFirst bool +} + +// HandlerFunc is the function signature required for a message route handler. +type HandlerFunc func(*discordgo.Session, *discordgo.Message, *Context) + +// Mux is the main struct for all mux methods. +type Mux struct { + Routes []*Route + Default *Route + Prefix string +} + +// New returns a new Discord message route mux +func New() *Mux { + m := &Mux{} + m.Prefix = "!" + return m +} + +// Route allows you to register a route +func (m *Mux) Route(pattern, desc string, cb HandlerFunc) (*Route, error) { + r := Route{} + r.Pattern = pattern + r.Description = desc + r.Run = cb + m.Routes = append(m.Routes, &r) + + return &r, nil +} + +// FuzzyMatch attempts to find the best route match for a given message. +func (m *Mux) FuzzyMatch(msg string) (*Route, []string) { + // Tokenize the msg string into a slice of words + fields := strings.Fields(msg) + + // no point to continue if there's no fields + if len(fields) == 0 { + return nil, nil + } + + // Search though the command list for a match + var r *Route + var rank int + + var fk int + for fk, fv := range fields { + + for _, rv := range m.Routes { + // If we find an exact match, return that immediately. + if rv.Pattern == fv { + return rv, fields[fk:] + } + + // Some "Fuzzy" searching... + if strings.HasPrefix(rv.Pattern, fv) { + if len(fv) > rank { + r = rv + rank = len(fv) + } + } + } + } + return r, fields[fk:] +} + +// OnMessageCreate is a DiscordGo Event Handler function. This must be +// registered using the DiscordGo.Session.AddHandler function. This function +// will receive all Discord messages and parse them for matches to registered +// routes. +func (m *Mux) OnMessageCreate(ds *discordgo.Session, mc *discordgo.MessageCreate) { + var err error + + // Ignore all messages created by the Bot account itself + if mc.Author.ID == ds.State.User.ID { + return + } + + // Create Context struct that we can put various infos into + ctx := &Context{ + Content: strings.TrimSpace(mc.Content), + } + + // Fetch the channel for this Message + var c *discordgo.Channel + c, err = ds.State.Channel(mc.ChannelID) + if err != nil { + // Try fetching via REST API + c, err = ds.Channel(mc.ChannelID) + if err != nil { + log.Printf("unable to fetch Channel for Message, %s", err) + } else { + // Attempt to add this channel into our State + err = ds.State.ChannelAdd(c) + if err != nil { + log.Printf("error updating State with Channel, %s", err) + } + } + } + // Add Channel info into Context (if we successfully got the channel) + if c != nil { + if c.Type == discordgo.ChannelTypeDM { + ctx.IsPrivate, ctx.IsDirected = true, true + } + } + + // Detect @name or @nick mentions + if !ctx.IsDirected { + // Detect if Bot was @mentioned + for _, v := range mc.Mentions { + + if v.ID == ds.State.User.ID { + + ctx.IsDirected, ctx.HasMention = true, true + + reg := regexp.MustCompile(fmt.Sprintf("<@!?(%s)>", ds.State.User.ID)) + + // Was the @mention the first part of the string? + if len(reg.FindStringIndex(ctx.Content)) > 0 && reg.FindStringIndex(ctx.Content)[0] == 0 { + ctx.HasMentionFirst = true + } + + // strip bot mention tags from content string + ctx.Content = reg.ReplaceAllString(ctx.Content, "") + + break + } + } + } + + // Detect prefix mention + if !ctx.IsDirected && len(m.Prefix) > 0 { + // TODO : Must be changed to support a per-guild user defined prefix + if strings.HasPrefix(ctx.Content, m.Prefix) { + ctx.IsDirected, ctx.HasPrefix, ctx.HasMentionFirst = true, true, true + ctx.Content = strings.TrimPrefix(ctx.Content, m.Prefix) + } + } + + // For now, if we're not specifically mentioned we do nothing. + // later I might add an option for global non-mentioned command words + if !ctx.IsDirected { + return + } + + // Try to find the "best match" command out of the message. + r, fl := m.FuzzyMatch(ctx.Content) + if r != nil { + ctx.Fields = fl + r.Run(ds, mc.Message, ctx) + return + } + + // If no command match was found, call the default. + // Ignore if only @mentioned in the middle of a message + if m.Default != nil && (ctx.HasMentionFirst) { + // TODO: This could use a ratelimit + // or should the ratelimit be inside the cmd handler?.. + // In the case of "talking" to another bot, this can create an endless + // loop. Probably most common in private messages. + m.Default.Run(ds, mc.Message, ctx) + } + +}