diff --git a/Main.go b/Main.go new file mode 100644 index 0000000..aa6dfae --- /dev/null +++ b/Main.go @@ -0,0 +1,14 @@ +package main + +import ( + "geoffrey/commands" + + "os" +) + +func main() { + commands.RegisterCommand(&commands.ServerCommand{}) + commands.RegisterCommand(&commands.HelpCommand{}) + + commands.RunCommand(os.Args[1:]) +} diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..6b67261 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: geoffrey server \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fde939 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# GeoffreyBot +Geoffrey is a guinea pig/GroupMe bot with a new lease on life diff --git a/api/CatFacts.go b/api/CatFacts.go new file mode 100644 index 0000000..2cf5b89 --- /dev/null +++ b/api/CatFacts.go @@ -0,0 +1,48 @@ +package api + +import ( + "net/http" + "errors" + "strings" +) + +const catFactsBaseUrl = "https://catfact.ninja" + +const catFactEndpoint = catFactsBaseUrl + "/fact" +const catFactFactKey = "fact" + +func GetCatFact() (string, error) { + resp, err := http.Get(catFactEndpoint) + + if (err != nil) { + return "", err + } + + defer resp.Body.Close() + + if (resp.StatusCode != 200) { + return "", buildStatusError("Error recieved from cat facts endpoint", resp) + } + + body, err := getBodyJson(resp) + + if (err != nil) { + return "", err + } + + if fact, exists := body[catFactFactKey]; exists { + return sanitizeCatFact(fact), nil + } else { + return "", errors.New("Could not parse fact out of response body!") + } +} + +func GetCatFactAsync(channel chan StringResult) { + fact, err := GetCatFact() + channel <- StringResult{fact, err} +} + +func sanitizeCatFact(fact string) string { + fact = strings.Replace(fact, "\"", "'", -1) // Remove quote marks + return strings.Replace(fact, "\\", "", -1) // Remove backslashes +} \ No newline at end of file diff --git a/api/DropBox.go b/api/DropBox.go new file mode 100644 index 0000000..3a0228c --- /dev/null +++ b/api/DropBox.go @@ -0,0 +1,50 @@ +package api + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "errors" + "os" +) + +const dropboxContentBaseUrl = "https://content.dropboxapi.com/2/" +const dropboxFileDownloadUrl = dropboxContentBaseUrl + "files/download" + +const dropboxAccessTokenEnvVar = "DROPBOX_ACCESS_TOKEN" + +// DownloadFile retrieves a file from dropbox at the given path +// If is the caller's responsibility to close the file stream +func DownloadFile(filePath string) (io.ReadCloser, error) { + token := os.Getenv(dropboxAccessTokenEnvVar) + if token == "" { + return nil, errors.New("Could not download file; " + dropboxAccessTokenEnvVar + " env var not set") + } + + client := &http.Client{} + request, err := http.NewRequest("POST", dropboxFileDownloadUrl, nil) + addAuthHeader(token, request) + + arg := fmt.Sprintf(`{"path":"%v"}`, filePath) + + request.Header.Add("Dropbox-API-Arg", arg) + + resp, err := client.Do(request) + + if err != nil { + return nil, err + } + + if (resp.StatusCode != 200) { + respBody, _ := ioutil.ReadAll(resp.Body) + resp.Body.Close() + return nil, errors.New("Error downloading file: " + string(respBody)) + } + + return resp.Body, nil +} + +func addAuthHeader(token string, request *http.Request) { + request.Header.Add("Authorization", "Bearer " + token) +} \ No newline at end of file diff --git a/api/GroupMe.go b/api/GroupMe.go new file mode 100644 index 0000000..8e1d535 --- /dev/null +++ b/api/GroupMe.go @@ -0,0 +1,110 @@ +package api + +import ( + "io/ioutil" + "bytes" + "net/http" + "fmt" + "os" + "strconv" + "geoffrey/types" +) + +const groupMeBaseUrl = "https://api.groupme.com/v3" +const botPostMessageUrl = groupMeBaseUrl + "/bots/post" + +const jsonContentType = "application/json" + +const botIdEnvironmentVar = "BOT_ID" + +var botId string = "" + +func PostGroupMeMessage(message string) { + id := getBotId() + if (id == "") { + fmt.Printf("Cannot send message; $%v environment variable not set\n", botIdEnvironmentVar) + return + } + + body := fmt.Sprintf(`{ + "bot_id": "%v", + "text": "%v" + }`, id, message) + + postGroupMeMessage(body) +} + +func PostGroupMeMessageWithPicture(message string, imageUrl string) { + id := getBotId() + if (id == "") { + fmt.Printf("Cannot send message; $%v environment variable not set\n", botIdEnvironmentVar) + return + } + + body := fmt.Sprintf(`{ + "bot_id": "%v", + "text": "%v", + "picture_url": "%v" + }`, id, message, imageUrl) + + postGroupMeMessage(body) +} + +func PostGroupMeMessageWithMentions(message string, mentions ...types.GroupMeMessageMention) { + id := getBotId() + if (id == "") { + fmt.Printf("Cannot send message; $%v environment variable not set\n", botIdEnvironmentVar) + return + } + + var lociArr = "" + var userIdArr = "" + + if (len(mentions) > 0) { + for _, mention := range(mentions) { + lociArr += "[" + strconv.Itoa(mention.StartIndex) + "," + strconv.Itoa(mention.Length) + "]," + userIdArr += `"` + mention.UserId + `",` + } + + // Cut off extra commas + lociArr = lociArr[:len(lociArr)-1] + userIdArr = userIdArr[:len(userIdArr)-1] + } + + body := fmt.Sprintf(`{ + "bot_id": "%v", + "text": "%v", + "attachments": [{ + "type": "mentions", + "loci": [%v], + "user_ids": [%v] + }] + }`, botId, message, lociArr, userIdArr) + + postGroupMeMessage(body) +} + +// postGroupMeMessage is the internal function that both public post functions delegate to +func postGroupMeMessage(postBody string) { + resp, err := http.Post(botPostMessageUrl, jsonContentType, bytes.NewBufferString(postBody)) + + if (err != nil) { + fmt.Printf("Error posting message; %v", err) + return + } + + if (resp.StatusCode != 202) { + respBody, _ := ioutil.ReadAll(resp.Body) + fmt.Printf("Post failed; response code: %v: %v;\n\tbody: %v\n", resp.StatusCode, resp.Status, string(respBody)) + } + + resp.Body.Close() +} + +func getBotId() string { + if botId == "" { + botId = os.Getenv(botIdEnvironmentVar) + } + + return botId +} \ No newline at end of file diff --git a/api/GroupMeImages.go b/api/GroupMeImages.go new file mode 100644 index 0000000..39f649e --- /dev/null +++ b/api/GroupMeImages.go @@ -0,0 +1,61 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" +) + +type responseData struct { + Data payload `json:"payload"` +} + +type payload struct { + Url string `json:"url"` + PictureUrl string `json:"picture_url"` +} + +const groupMeImageServiceUrl = "https://image.groupme.com/pictures?access_token=%v" + +const groupMeAccessTokenEnvVar = "GM_ACCESS_TOKEN" + +// Process Image posts the given image to GroupMe's image service +// and returns the group me url of the image +func ProcessImage(image io.Reader) string { + token := os.Getenv(groupMeAccessTokenEnvVar) + if token == "" { + fmt.Println("Cannot process image; GM_ACCESS_TOKEN env var not set") + return "" + } + + url := fmt.Sprintf(groupMeImageServiceUrl, token) + + resp, err := http.Post(url, "image/jpeg", image) + + defer resp.Body.Close() + + if err != nil { + fmt.Printf("Error from GroupMe image servce: %v", err) + return "" + } + + respBody, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + return string(respBody) + } + + respPayload := responseData{} + + err = json.Unmarshal(respBody, &respPayload) + + if err != nil { + fmt.Printf("Error converting response body to JSON: %v\n", err) + return "" + } + + return respPayload.Data.PictureUrl +} diff --git a/api/NetUtils.go b/api/NetUtils.go new file mode 100644 index 0000000..a0a8f6a --- /dev/null +++ b/api/NetUtils.go @@ -0,0 +1,34 @@ +package api + +import ( + "strconv" + "encoding/json" + "net/http" + "io/ioutil" + "errors" +) + +type StringResult struct { + Result string + Err error +} + +// getBodyJson takes an http.Response pointer and reads all of its data into a map +func getBodyJson(response *http.Response) (map[string] string, error) { + bodyBytes, err := ioutil.ReadAll(response.Body) + + if (err != nil) { + return nil, err + } + + bodyMap := make(map[string] string) + + json.Unmarshal(bodyBytes, &bodyMap) + + return bodyMap, nil +} + +// buildStatusError takes an http response and builds an error with the status code and message in it +func buildStatusError(message string, response *http.Response) error { + return errors.New(message + "; Status " + strconv.Itoa(response.StatusCode) + ": " + response.Status) +} \ No newline at end of file diff --git a/api/Temporize.go b/api/Temporize.go new file mode 100644 index 0000000..a3fdf90 --- /dev/null +++ b/api/Temporize.go @@ -0,0 +1,64 @@ +package api + +import ( + "io/ioutil" + "net/http" + "net/url" + "fmt" + "time" + "math/rand" + "os" +) + +const temporizeURLEnvironmentVar = "TEMPORIZE_URL" + +var temporizeUrl = "" + +// ScheduleSingleEventInAWeek will schedule an event for approximately a week (4-7 days) from today +// at a random time around noon +func ScheduleSingleEventInAWeek(callbackUrl string) { + now := time.Now() + rand.Seed(now.Unix()) + + later := now.AddDate(0, 0, 4 + rand.Intn(3)) + later = time.Date(later.Year(), + later.Month(), + later.Day(), + rand.Intn(24), + rand.Intn(60), + 0, 0, time.UTC) + + ScheduleSingleEvent(later, callbackUrl) +} + +func ScheduleSingleEvent(t time.Time, callbackUrl string) { + baseUrl := getTemporizeUrl() + if baseUrl == "" { + fmt.Printf("Cannot schedule event; $%v environment variable not set\n", temporizeURLEnvironmentVar) + return + } + + url := fmt.Sprintf("%v/v1/events/%v/%v", baseUrl, t.Format(time.RFC3339), url.QueryEscape(callbackUrl)) + + resp, err := http.Post(url, "text/plain", nil) + + if (err != nil) { + fmt.Printf("Error scheduling event; %v", err) + return + } + + if (resp.StatusCode != 200) { + respBody, _ := ioutil.ReadAll(resp.Body) + fmt.Printf("Error scheduling event; Post failed; response code: %v: %v;\n\tbody: %v\n", resp.StatusCode, resp.Status, string(respBody)) + } + + resp.Body.Close() +} + +func getTemporizeUrl() string { + if temporizeUrl == "" { + temporizeUrl = os.Getenv(temporizeURLEnvironmentVar) + } + + return temporizeUrl +} \ No newline at end of file diff --git a/api/YesOrNo.go b/api/YesOrNo.go new file mode 100644 index 0000000..4035fba --- /dev/null +++ b/api/YesOrNo.go @@ -0,0 +1,67 @@ +package api + +import ( + "time" + "math/rand" + "net/http" +) + +type YesOrNoAnswer int +type YesOrNoResponse struct { + Answer YesOrNoAnswer + ImageUrl string +} + +const ( + YES YesOrNoAnswer = iota + NO + MAYBE +) + +const yesOrNoUrl = "https://yesno.wtf/api" +const forceMaybeUrl = yesOrNoUrl + "/?force=maybe" + +func GetYesOrNo() (YesOrNoResponse, error) { + var retval YesOrNoResponse + url := yesOrNoUrl + + if shouldForceMaybe() { + url = forceMaybeUrl + } + + resp, err := http.Get(url) + + if (err != nil) { + return retval, err + } + + defer resp.Body.Close() + + if (resp.StatusCode != 200) { + return retval, buildStatusError("Error recieved from cat facts endpoint", resp) + } + + // Get the body and parse it into a map + body, err := getBodyJson(resp) + + if (err != nil) { + return retval, err + } + + // Everything has gone well, read the response now + switch body["answer"] { + case "yes": retval.Answer = YES + case "no": retval.Answer = NO + default: retval.Answer = MAYBE + } + + retval.ImageUrl = body["image"] + + return retval, nil +} + +// Force maybe ~1/15 times to make things more interesting +func shouldForceMaybe() bool { + rand.Seed(time.Now().Unix()) + return rand.Intn(15) == 1 +} \ No newline at end of file diff --git a/commands/ConsoleCommand.go b/commands/ConsoleCommand.go new file mode 100644 index 0000000..a376ad9 --- /dev/null +++ b/commands/ConsoleCommand.go @@ -0,0 +1,67 @@ +package commands + +import ( + "fmt" + "geoffrey/types" +) + +const CommandNotFoundCode = -100 + +var commands []types.ConsoleCommand + +func RegisterCommand(cmd types.ConsoleCommand) { + commands = append(commands, cmd) +} + +// RunCommand takes an slice of strings and attempts to run the command associated with it. +// The first string in the slice is used to determine the command name, the rest are pass into +// the command as arguments +func RunCommand(commandStrings []string) int { + if len(commandStrings) > 0 { + for _, cmd := range commands { + if cmd.Name() == commandStrings[0] { + return cmd.Execute(commandStrings[1:]) + } + } + + fmt.Printf("Command not found! (%v)\n", commandStrings[0]) + } else { + fmt.Println("Please specify a command:") + } + + (&HelpCommand{}).Execute(nil) + + return CommandNotFoundCode +} + +// ********** HELP COMMAND + +type HelpCommand struct{} + +func (*HelpCommand) Name() string { return "help" } +func (*HelpCommand) Usage() string { return "help - List all commands and their usage text\n" + + "\thelp ... - Print usage text for the given cmds" } + +func (*HelpCommand) Execute(args []string) int { + var cmdsToPrint []types.ConsoleCommand + + if len(args) == 0 { + // No args? print help for all commands! + cmdsToPrint = commands + } else { + // Some args? print help only for the given commands + for _, cmd := range commands { + for _, arg := range args { + if cmd.Name() == arg { + cmdsToPrint = append(cmdsToPrint, cmd) + } + } + } + } + + for _, cmd := range cmdsToPrint { + fmt.Printf("%v usage:\n\t%v\n\n", cmd.Name(), cmd.Usage()) + } + + return 0 +} \ No newline at end of file diff --git a/commands/Server.go b/commands/Server.go new file mode 100644 index 0000000..d0f6bfc --- /dev/null +++ b/commands/Server.go @@ -0,0 +1,51 @@ +package commands + +import ( + "geoffrey/endpoints" + + "log" + "fmt" + "net/http" + "os" +) + +type ServerCommand struct{} + +func (*ServerCommand) Name() string { return "server" } +func (*ServerCommand) Usage() string { return "server - Start the Geoffrey server on port $PORT"} + +func (*ServerCommand) Execute(args []string) int { + addr, err := determineListenAddress() + if err != nil { + log.Fatal(err) + } + + registerHandlers() + + log.Printf("Listening on %s...\n", addr) + if err := http.ListenAndServe(addr, nil); err != nil { + panic(err) + } + + return 0 +} + +func determineListenAddress() (string, error) { + port := os.Getenv("PORT") + if port == "" { + return "", fmt.Errorf("$PORT not set") + } + + return ":" + port, nil +} + +// registerHandlers loops over all endpoints in the registry and tells +// the server to handle them +func registerHandlers() { + fmt.Println("Registering Endpoints...") + + for path, handler := range endpoints.GetAllEndpoints() { + fmt.Printf("Registering Handler for %v\n", path) + http.HandleFunc(path, handler) + } +} diff --git a/endpoints/EndpointRegistry.go b/endpoints/EndpointRegistry.go new file mode 100644 index 0000000..ad52a1f --- /dev/null +++ b/endpoints/EndpointRegistry.go @@ -0,0 +1,19 @@ +package endpoints + +import ( + "net/http" +) + +type HandlerFunc func(http.ResponseWriter, *http.Request) + +var endpoints = map[string] HandlerFunc { + "/" : fallbackHandler, + "/message" : messageRecieved, + "/skill/passive" : passiveSkillRequest, +} + +// GetAllEndpoints returns all registered endpoints in a map +// The map contains the String path of the endpoint mapped to its handler func +func GetAllEndpoints() map[string] HandlerFunc { + return endpoints +} \ No newline at end of file diff --git a/endpoints/FallbackHandler.go b/endpoints/FallbackHandler.go new file mode 100644 index 0000000..42081bb --- /dev/null +++ b/endpoints/FallbackHandler.go @@ -0,0 +1,12 @@ +package endpoints + +import ( + "fmt" + "net/http" +) + +func fallbackHandler(w http.ResponseWriter, r *http.Request) { + fmt.Printf("Request handled by fallback handler; path - %v\n", r.URL.Path) + + fmt.Fprintln(w, "You've reached Geoffrey, please leave a message after the beep...") +} \ No newline at end of file diff --git a/endpoints/MessageRecieved.go b/endpoints/MessageRecieved.go new file mode 100644 index 0000000..8a30b2c --- /dev/null +++ b/endpoints/MessageRecieved.go @@ -0,0 +1,68 @@ +package endpoints + +import ( + "geoffrey/skills" + "geoffrey/types" + "encoding/json" + "io/ioutil" + "fmt" + "net/http" +) + +const postBodyMessageId = "id" +const postBodyMessageText = "text" +const postBodyGroupId = "group_id" +const postBodySenderName = "name" +const postBodySenderType = "sender_type" + +// messageRecieved handles POST requests from GroupMe that get sent for each message sent +// in the group that the bot is in +func messageRecieved(w http.ResponseWriter, r *http.Request) { + fmt.Printf("Message Recieved!!\n") + + defer r.Body.Close() + + body, err := ioutil.ReadAll(r.Body) + + if (err != nil) { + fmt.Printf("Error reading request body: %v\n", err) + return + } + + bodyMap := make(map[string] interface{}) + err = json.Unmarshal(body, &bodyMap) + + if (err != nil) { + fmt.Printf("Error unmarshalling body into map; %v", err) + return + } + + logMessage(bodyMap) + message := buildMessageStruct(bodyMap) + + // Only process messages from humans + if (message.SenderType != "user") { + return + } + + for _, skill := range skills.GetActiveSkills() { + if (skill(message)) { + break + } + } +} + +func buildMessageStruct(bodyMap map[string] interface{}) types.GroupMeMessagePost { + var msg types.GroupMeMessagePost + msg.Id = bodyMap[postBodyMessageId].(string) + msg.GroupId = bodyMap[postBodyGroupId].(string) + msg.Sender = bodyMap[postBodySenderName].(string) + msg.SenderType = bodyMap[postBodySenderType].(string) + msg.MessageText = bodyMap[postBodyMessageText].(string) + + return msg +} + +func logMessage(body map[string] interface{}) { + fmt.Printf("Message (id: %v) from %v in group %v\n", body[postBodyMessageId], body[postBodySenderName], body[postBodyGroupId]) +} diff --git a/endpoints/PassiveSkillRequest.go b/endpoints/PassiveSkillRequest.go new file mode 100644 index 0000000..c049ad5 --- /dev/null +++ b/endpoints/PassiveSkillRequest.go @@ -0,0 +1,59 @@ +package endpoints + +import ( + "net/http" + "fmt" + "os" + + "geoffrey/api" + "geoffrey/skills" +) + +const skillNameQueryParam = "skill" + +const rescheduleQueryParam = "reschedule" +const rescheduleWeekQueryParam = "week" + +func passiveSkillRequest(w http.ResponseWriter, r *http.Request) { + fmt.Println("Passive Skill Request recieved!!"); + + skillsParam, ok := r.URL.Query()[skillNameQueryParam] + + if !ok || len(skillsParam) < 1 { + fmt.Printf("Missing %v query param!\n", skillNameQueryParam) + w.WriteHeader(400) + return + } + + // Get the first skill in the array + skill := skills.GetPassiveSkillByName(skillsParam[0]) + + if skill == nil { + fmt.Printf("Skill (%v) not found!\n", skillsParam[0]) + w.WriteHeader(400) + return + } + + fmt.Printf("Running passive skill %v...\n", skillsParam[0]) + skill() + + rescheduleParams, ok := r.URL.Query()[rescheduleQueryParam] + + if ok && len(rescheduleParams) >= 1 { + if rescheduleParams[0] == rescheduleWeekQueryParam { + fmt.Println("Rescheduling event for ~1 week") + fmt.Printf("rescheuling at: %v\n", getServerName() + r.URL.RequestURI()) + api.ScheduleSingleEventInAWeek(getServerName() + r.URL.RequestURI()) + } else { + fmt.Printf("Unexpected reschedule duration: %t\n", rescheduleParams[0]) + w.WriteHeader(400) + return + } + } + + w.WriteHeader(200) +} + +func getServerName() string { + return os.Getenv("SELF_URL") +} diff --git a/skills/CatFactSkill.go b/skills/CatFactSkill.go new file mode 100644 index 0000000..2dc9024 --- /dev/null +++ b/skills/CatFactSkill.go @@ -0,0 +1,107 @@ +package skills + +import ( + "fmt" + "strings" + "geoffrey/api" + "geoffrey/types" +) + +var greetingOptions = []string { + "Hi %v!", + "Hi there %v!", + "Hey %v!", + "What's crackin' %v?!", + "Hello there %v.", +} + +var passiveGreetingSubjectOptions = []string { + "y'all", + "guys", + "everyone", + "fam", + "friends", + "losers", +} + +var factPrefixOptions = []string { + "Did you know %v?", + "Have you heard that %v?", + "Can you believe that %v?", + "Wanna hear a fun cat fact?? Here ya go: %v.", + "Guess what! Ahh you'll never guess I'll just tell you: %v!", +} + +// catFactActiveSkill sends the group a cat fact if geoffrey is +// mentioned in a message +func catFactActiveSkill(message types.GroupMeMessagePost) bool { + if (!isGeoffreyMentioned(message.MessageText)) { + return false + } + + // Get only the first name of the sender + sender := message.Sender + indexOfSpace := strings.Index(sender, " ") + if indexOfSpace > 0 { + sender = sender[:indexOfSpace] + } + + finalMessage, err := getCatFact(sender) + if err != nil { + fmt.Printf("Error getting cat fact for cat fact skill; %v", err) + return false + } + + api.PostGroupMeMessage(finalMessage) + + return true +} + +// catFactPassiveSkill sends a random cat fact to the group un prompted! +func catFactPassiveSkill() { + finalMessage, err := getCatFact(pickRandomFromStringArray(passiveGreetingSubjectOptions)) + if err != nil { + fmt.Printf("Error getting cat fact for cat fact skill; %v", err) + return + } + + api.PostGroupMeMessage(finalMessage) +} + +// getCatFact gets the cat fact from the API and formats the message while its waiting for a response +func getCatFact(name string) (string, error) { + // Go get the cat fact while we figure out who to @ + catFactChannel := make(chan api.StringResult) + go api.GetCatFactAsync(catFactChannel) + + + greeting := fmt.Sprintf(pickRandomFromStringArray(greetingOptions), name) + factFormat := string(pickRandomFromStringArray(factPrefixOptions)) + factWithoutPunctuation := factFormat[:len(factFormat) - 1] + factPunctuation := string(factFormat[len(factFormat) - 1]) + + catFactResult := <-catFactChannel + + if (catFactResult.Err != nil) { + return "", catFactResult.Err + } + + fact := formatCatFact(catFactResult.Result, factPunctuation) + + finalMessage := greeting + " " + fmt.Sprintf(factWithoutPunctuation, fact) + + return finalMessage, nil +} + +func formatCatFact(fact string, punctuation string) string { + // Some cat facts are multiple sentences, find the first punctuation so we can replace it later + var puncuationIndex = strings.IndexAny(fact, ".?!") + if puncuationIndex == -1 { + puncuationIndex = len(fact) - 1 + } + + // Force the first letter to be lower case + firstLetter := strings.ToLower(fact[:1]) + + return firstLetter + fact[1:puncuationIndex] + punctuation + fact[puncuationIndex+1:] +} diff --git a/skills/GenericQuestionSkill.go b/skills/GenericQuestionSkill.go new file mode 100644 index 0000000..290d4ae --- /dev/null +++ b/skills/GenericQuestionSkill.go @@ -0,0 +1,44 @@ +package skills + +import ( + "geoffrey/types" + "geoffrey/api" + "fmt" + "strings" + "net/url" +) + +var answerOptions = []string { + "Uhh I don't really know how to answer that...", + "Hey listen idk alright leave me out of this.", + "Believe me if I knew I'd tell you!", + "I'm not even going to justify that question with an answer...", + "I'm just a guinea pig I don't know these things!", + "Ugh why do I have to do everything around here... http://lmgtfy.com/?q=%v", + "Listen that's not my problem buddy good luck figuring it out though.", +} + +func genericQuestionSkill(message types.GroupMeMessagePost) bool { + + // First check if geoffrey is mentioned + if (!isGeoffreyMentioned(message.MessageText)) { + return false + } + + messageTextWithoutMention := stripGeoffreyMentions(message.MessageText) + + // Next check if it's a question + if (!isQuestion(messageTextWithoutMention)) { + return false + } + + response := pickRandomFromStringArray(answerOptions) + + // If it's the lmgtfy response, throw in the query string + if (strings.Contains(response, "lmgtfy")) { + response = fmt.Sprintf(response, url.QueryEscape(messageTextWithoutMention)) + } + + api.PostGroupMeMessage(response) + return true +} \ No newline at end of file diff --git a/skills/NostalgiaSkill.go b/skills/NostalgiaSkill.go new file mode 100644 index 0000000..4ee973c --- /dev/null +++ b/skills/NostalgiaSkill.go @@ -0,0 +1,76 @@ +package skills + +import ( + "fmt" + "math/rand" + "time" + "strconv" + "os" + + "geoffrey/api" +) + +const nostalgiaPicFilePath = "/pics/%v.jpg" +const numNostalgiaPicsEnvVar = "NUM_NOSTALGIA_PICS" + +var passiveGreetingOptions = []string { + "Let's go on a stroll down memory lane...", + "Hahahaha remember this?? (I don't...ya know cuz you guys kicked me out and all...)", + "Woah check out this blast from the past:", + "Ahhh those were the days...", + "How come no one ever invites me to cool stuff like this?", + "Woah when did this happen?", + "You guys sure had a good time without me...#FOMO", + "What's the story behind this beauty??", +} + +func nostalgiaPassiveSkill() { + pictureUrl := getRandomNostalgiaPicUrl() + + if pictureUrl != "" { + fmt.Printf("Random nostalgia pic url: %v\n", pictureUrl) + + messageText := pickRandomFromStringArray(passiveGreetingOptions) + + api.PostGroupMeMessageWithPicture(messageText, pictureUrl) + } else { + postErrorMessage() + } +} + +func getRandomNostalgiaPicUrl() string { + numNostalgiaPicsStr := os.Getenv(numNostalgiaPicsEnvVar) + if numNostalgiaPicsStr == "" { + fmt.Printf("Could not retrieve random nostalgia pic; %v env var not set!\n", numNostalgiaPicsEnvVar) + return "" + } + + numNostalgiaPics, _ := strconv.Atoi(numNostalgiaPicsStr) + + rand.Seed(time.Now().Unix()) + // I started the pics at 1 instead of 0, sue me + randIndex := 1 + (rand.Int() % numNostalgiaPics) + filePath := fmt.Sprintf(nostalgiaPicFilePath, randIndex) + + fmt.Printf("Random nostalgia pic index: %v\n", randIndex) + fmt.Printf("Downloading dropbox file %v...\n", filePath) + + file, err := api.DownloadFile(filePath) + + if err != nil { + fmt.Printf("Error downloading file: %v\n", err) + return "" + } + + defer file.Close() + + fmt.Printf("Processing %v in GroupMe Image service...\n", filePath) + + processedUrl := api.ProcessImage(file) + + if processedUrl == "" { + fmt.Printf("Error processing image with GroupMe Image service :(\n") + } + + return processedUrl +} diff --git a/skills/RoasterSkill.go b/skills/RoasterSkill.go new file mode 100644 index 0000000..8328057 --- /dev/null +++ b/skills/RoasterSkill.go @@ -0,0 +1,140 @@ +package skills + +import ( + "time" + "math/rand" + "geoffrey/api" + "geoffrey/types" +) + +type Roast struct { + text string + mention types.GroupMeMessageMention +} + +// map of string (user id) to potential roasts +// user id of "" can apply to anyone +var roasts = map[string] []Roast { + "21004947": { // Johnny + { + text: "@Johnny Bollash when're you gonna drop the whole 'Plutarch' thing and admit your middle name is Francis?", + mention: types.GroupMeMessageMention { + UserId: "21004947", + StartIndex: 0, + Length: len("@Johnny Bollash"), + }, + }, + { + text: "@Johnny Bollash Connecticut is small. Boom roasted", + mention: types.GroupMeMessageMention { + UserId: "21004947", + StartIndex: 0, + Length: len("@Johnny Bollash"), + }, + }, + }, + "20596690": { // Anokhi + { + text: "@Anohki Patel I was going through some of the old messages I missed while I was gone and...I'm a f**king guinea pig alright??", + mention: types.GroupMeMessageMention { + UserId: "20596690", + StartIndex: 0, + Length: len("@Anohki Patel"), + }, + }, + }, + "18172472": { // Apurva + { + text: "@Apurva Kasam you live in Missouri. Boom roasted", + mention: types.GroupMeMessageMention { + UserId: "18172472", + StartIndex: 0, + Length: len("@Apurva Kasam"), + }, + }, + }, + "20626795": { // Michael + { + text: "@Michael Moghaddam you write shitty bots", + mention: types.GroupMeMessageMention { + UserId: "20626795", + StartIndex: 0, + Length: len("@Michael Moghaddam"), + }, + }, + { + text: "@Michael Moghaddam D.C. isn't part of Maryland. Get over yourself.", + mention: types.GroupMeMessageMention { + UserId: "20626795", + StartIndex: 0, + Length: len("@Michael Moghaddam"), + }, + }, + }, + "17123786": { // Heman + { + text: "@Hemanth Koralla...do I know you?", + mention: types.GroupMeMessageMention { + UserId: "17123786", + StartIndex: 0, + Length: len("@Hemanth Koralla"), + }, + }, + }, + "20868132": { // David + { + text: "@David morrison why didn't you capitalize the 'm' in your last name?", + mention: types.GroupMeMessageMention { + UserId: "20868132", + StartIndex: 0, + Length: len("@David morrison"), + }, + }, + }, + "22602314": { // Kaie + { + text: "@Kaie Westmaas have you moved to Japan yet?", + mention: types.GroupMeMessageMention { + UserId: "22602314", + StartIndex: 0, + Length: len("@Kaie Westmaas"), + }, + }, + }, + "21405378": { // Ryan + { + text: "@Ryan Miller You dress like a dad. Boom roasted.", + mention: types.GroupMeMessageMention { + UserId: "21405378", + StartIndex: 0, + Length: len("@Ryan Miller"), + }, + }, + }, + "21498740": { // Vicki + { + text: "@Victoria Kravets got any new stalkers recently?", + mention: types.GroupMeMessageMention { + UserId: "21498740", + StartIndex: 0, + Length: len("@Victoria Kravets"), + }, + }, + }, +} + +func roasterPassiveSkill() { + roast := getRandomRoast() + + api.PostGroupMeMessageWithMentions(roast.text, roast.mention) +} + +func getRandomRoast() Roast { + var allRoasts []Roast + for _, roastList := range roasts { + allRoasts = append(allRoasts, roastList...) + } + + rand.Seed(time.Now().Unix()) + return allRoasts[rand.Intn(len(allRoasts))] +} \ No newline at end of file diff --git a/skills/SkillUtils.go b/skills/SkillUtils.go new file mode 100644 index 0000000..b3edc7c --- /dev/null +++ b/skills/SkillUtils.go @@ -0,0 +1,97 @@ +package skills + +import ( + "math/rand" + "time" + "strings" + + "geoffrey/api" + "geoffrey/types" +) + +var aliases = []string { + "geoffrey", + "geoff", +} + +const postBodyMessageText = "text" +const postBodySenderName = "sender" + +// isGeoffreyMentioned checks the given messageText for any instances of geoffrey's aliases +// preceeded by an '@' +func isGeoffreyMentioned(messageText string) bool { + // Case doesn't matter! + var lowerCaseMessage = strings.ToLower(messageText) + + // Loop over each of Geoffrey's aliases to see if he's been mentioned in the message + for _, alias := range aliases { + if strings.Contains(lowerCaseMessage, "@" + alias) { + return true + } + } + + return false +} + +// stripGeoffreyMentions removes all @aliases from the given string +// Note the returned string also is all lower case +func stripGeoffreyMentions(message string) string { + var lowerCaseMessage = strings.ToLower(message) + + for _, alias := range aliases { + lowerCaseMessage = strings.Replace(lowerCaseMessage, "@" + alias, "", -1) + } + + return strings.TrimSpace(lowerCaseMessage) +} + +// isQuestion checks if the given message text is a question or not +func isQuestion(message string) bool { + // Naively just check if there's a question mark in the string :\ + return strings.Contains(message, "?") +} + +// isYesOrNoQuestion checks if the given message text is a yes or no question +func isYesOrNoQuestion(message string) bool { + // First confirm it's a generic question + if (!isQuestion(message)) { + return false + } + + // Case does't matter! + message = strings.ToLower(message) + + // Now check if it starts with a yes/no question starter + for _, starter := range []string { "do", "did", "should", "will", "am", "is", "are" } { + if (strings.Contains(message, starter)) { + return true + } + } + + return false +} + +// pickRandomFromArray returns a random string from the given array +func pickRandomFromStringArray(arr []string) string { + rand.Seed(time.Now().Unix()) + return arr[rand.Int() % len(arr)] +} + +var errorMessageOptions = []string { + "I don't feel so good...", + "Maybe try checking my logs every once in a while :/", + "I've got a cyber stomach ache pls help", +} + +// postErrorMessage sends a message to the groupme indicating something went wrong +func postErrorMessage() { + mention := types.GroupMeMessageMention { + UserId: "20626795", + StartIndex: 0, + Length: len("@Michael Moghaddam"), + } + + message := "@Michael Moghaddam " + pickRandomFromStringArray(errorMessageOptions) + + api.PostGroupMeMessageWithMentions(message, mention) +} diff --git a/skills/SkillsRegistry.go b/skills/SkillsRegistry.go new file mode 100644 index 0000000..98a8292 --- /dev/null +++ b/skills/SkillsRegistry.go @@ -0,0 +1,58 @@ +package skills + +import ( + "fmt" + "time" + "math/rand" + "geoffrey/types" +) + +// A Skill is a function that takes a map containing the POST body of a GroupMe message POST +// and may or may not perform an action based on it + +// ActiveSkills return true/false depending on whether or not they consumed the event +type ActiveSkill func(types.GroupMeMessagePost) bool + +// Passive skills just do their thing man +type PassiveSkill func() + +var activeSkills = []ActiveSkill { + yesOrNoSkill, + genericQuestionSkill, + catFactActiveSkill, +} + +// GetActiveSkills returns all registered active skills in order or priority +func GetActiveSkills() []ActiveSkill { + return activeSkills +} + +var passiveSkills = map[string] PassiveSkill { + "summon": summonSkill, + "cat-fact": catFactPassiveSkill, + "roast": roasterPassiveSkill, + "nostalgia": nostalgiaPassiveSkill, + "nostalgia-rand-bump": nostalgiaPassiveSkill, // Bump up the likelihood of this skill getting picked randomly +} + +func GetPassiveSkillByName(name string) PassiveSkill { + if (name == "random") { + rand.Seed(time.Now().Unix()) + + // Calculate the random index to use + var target = rand.Intn(len(passiveSkills)) + var idx = 0 + // Iterate over the map until we hit target, then return the skill + // Side note: I can't believe there isn't a better way to do this :\ + for name, skill := range passiveSkills { + if (idx == target) { + fmt.Printf("Getting random skill...%v\n", name) + return skill + } + + idx++ + } + } + + return passiveSkills[name] +} \ No newline at end of file diff --git a/skills/SummonSkill.go b/skills/SummonSkill.go new file mode 100644 index 0000000..dbbfbb0 --- /dev/null +++ b/skills/SummonSkill.go @@ -0,0 +1,19 @@ +package skills + +import ( + "os" + "geoffrey/api" + "geoffrey/types" +) + +// summons the beast... +func summonSkill() { + var messageText = "@Kaie Westmaas" + var mention = types.GroupMeMessageMention { + UserId: os.Getenv("SUMMON_USER_ID"), + StartIndex: 0, + Length: len(messageText), + } + + api.PostGroupMeMessageWithMentions(messageText, mention) +} diff --git a/skills/YesOrNoSkill.go b/skills/YesOrNoSkill.go new file mode 100644 index 0000000..0bcef3c --- /dev/null +++ b/skills/YesOrNoSkill.go @@ -0,0 +1,62 @@ +package skills + +import ( + "fmt" + "geoffrey/api" + "geoffrey/types" +) + +var responseOptionMap = map[api.YesOrNoAnswer] []string { + api.YES: []string { + "Yes!", + "Yea sure why not.", + "I don't see why not!", + "Most definitely!", + "mhmm", + "Yaaaasssssss", + "Absolutely!", + }, + api.NO: []string { + "No!", + "Nah I don't think so.", + "I'm gonna go with...no", + "Ehhh maybe some other time pal.", + "Not a chance.", + "Just...no.", + "Nope.", + }, + api.MAYBE: []string { + "Hmm that's a tough one...", + "idk", + "Meh...maybe.", + "Ahhh who cares?", + "Just do what you want it doesn't really matter", + }, +} + +func yesOrNoSkill(message types.GroupMeMessagePost) bool { + // First check if geoffrey is mentioned + if (!isGeoffreyMentioned(message.MessageText)) { + return false + } + + messageTextWithoutMention := stripGeoffreyMentions(message.MessageText) + + // Next check if it's a yes or no question + if (!isYesOrNoQuestion(messageTextWithoutMention)) { + return false + } + + response, err := api.GetYesOrNo() + + if (err != nil) { + fmt.Printf("Error getting Yes or No response: %v", err) + return false + } + + messageText := pickRandomFromStringArray(responseOptionMap[response.Answer]) + + api.PostGroupMeMessageWithPicture(messageText, response.ImageUrl) + + return true +} \ No newline at end of file diff --git a/types/Types.go b/types/Types.go new file mode 100644 index 0000000..19b41ac --- /dev/null +++ b/types/Types.go @@ -0,0 +1,32 @@ +package types + +// This struct encapsulates the information in a group me message post request +type GroupMeMessagePost struct { + Id string + GroupId string + Sender string + SenderType string + MessageText string +} + +// This struct represents a mention in a group me message +// StartIndex refers to the start index of the mention in the message text +// Length refers to the length of the substring mention text +type GroupMeMessageMention struct { + UserId string + StartIndex int + Length int +} + +type ConsoleCommand interface { + // Name returns the name of this command + Name() string + + // Usage returns a short help string to be displayed when the user asks + // for help + Usage() string + + // Execute should run the command and return a result code. + // 0 for success, anything else for error + Execute(args []string) int +} \ No newline at end of file diff --git a/vendor/vendor.json b/vendor/vendor.json new file mode 100644 index 0000000..8fff69a --- /dev/null +++ b/vendor/vendor.json @@ -0,0 +1,6 @@ +{ + "comment": "", + "ignore": "test", + "package": [], + "rootPath": "geoffrey" +}