Initial import...

master
Matthieu Lalonde 5 years ago
commit 6146800788

4
.gitignore vendored

@ -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…
Cancel
Save