commit
6146800788
@ -0,0 +1,4 @@
|
||||
.env
|
||||
*~
|
||||
bin/
|
||||
users.json
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -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=
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 <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"
|
||||
}
|
||||
@ -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"`
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
@ -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": "<Altspace VR Username> <Discord Mention> [Discord Emoji]",
|
||||
"aemoji": "<Discord Mention> <Discord Emoji>",
|
||||
"msg": "<Discord Mention> ...",
|
||||
"accept": "(As reply) <Discord Mention> [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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
@ -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 <https://github.com/bwmarrin/disgord>*\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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in new issue