From 75cb8fbf31867130675c64db4269483a9a827291 Mon Sep 17 00:00:00 2001 From: ronaudinho Date: Tue, 6 Oct 2020 20:07:32 +0700 Subject: [PATCH] add parser for URL that returns image - if pesan contains image(s), reply will be image(s) with caption - if pesan contains image(s) and json URL, caption will contain parsed JSON response - if there are more than 1 images, only image from the last URL is captioned --- cmd/testserver/main.go | 46 +++++++++++++++ go.mod | 1 + go.sum | 2 + main.go | 56 ++++++++++++------ parser.go | 85 ++++++++++++++++++++------ parser_test.go | 131 ++++++++++++++++++++++++++++++----------- whatsappHandler.go | 75 ++++++++++++----------- 7 files changed, 292 insertions(+), 104 deletions(-) create mode 100644 cmd/testserver/main.go diff --git a/cmd/testserver/main.go b/cmd/testserver/main.go new file mode 100644 index 0000000..495641d --- /dev/null +++ b/cmd/testserver/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "bytes" + "encoding/json" + "image" + "image/color" + "image/draw" + "image/jpeg" + "net/http" + "strconv" +) + +var ( + testTextURL1 = "/text/1" + testTextURL2 = "/text/2" + testTextURL3 = "/text/3" + testImageURL1 = "/image/1" +) + +// sets up required value here +func main() { + http.HandleFunc(testTextURL1, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(json.RawMessage(`{"message": "1"}`)) + }) + http.HandleFunc(testTextURL2, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(json.RawMessage(`{"massage": "2"}`)) + }) + http.HandleFunc(testImageURL1, func(w http.ResponseWriter, r *http.Request) { + m := image.NewRGBA(image.Rect(0, 0, 240, 240)) + blue := color.RGBA{0, 0, 255, 255} + draw.Draw(m, m.Bounds(), &image.Uniform{blue}, image.ZP, draw.Src) + buffer := new(bytes.Buffer) + err := jpeg.Encode(buffer, m, nil) + if err != nil { + return + } + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Content-Length", strconv.Itoa(len(buffer.Bytes()))) + w.Write(buffer.Bytes()) + }) + + http.ListenAndServe(":6969", nil) +} diff --git a/go.mod b/go.mod index 02c9463..452e8ba 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/Rhymen/go-whatsapp v0.1.0 github.com/aws/aws-sdk-go v1.34.25 github.com/goccy/go-yaml v1.8.2 + github.com/gorilla/mux v1.8.0 github.com/imroc/req v0.3.0 github.com/joho/godotenv v1.3.0 github.com/robfig/cron/v3 v3.0.1 diff --git a/go.sum b/go.sum index 707863b..5dda4ca 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/goccy/go-yaml v1.8.2/go.mod h1:wS4gNoLalDSJxo/SpngzPQ2BN4uuZVLCmbM4S3 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.0 h1:kbxbvI4Un1LUWKxufD+BiE6AEExYYgkQLQmLFqA1LFk= github.com/golang/protobuf v1.3.0/go.mod h1:Qd/q+1AKNOZr9uGQzbzCmRO6sUih6GTPZv6a1/R87v0= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 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/imroc/req v0.3.0 h1:3EioagmlSG+z+KySToa+Ylo3pTFZs+jh3Brl7ngU12U= diff --git a/main.go b/main.go index 5de8abe..85a6ad0 100644 --- a/main.go +++ b/main.go @@ -1,28 +1,27 @@ package main import ( + "bytes" "encoding/gob" - "math/rand" - "os/signal" - "fmt" "log" + "math/rand" "os" - "time" + "os/signal" "strings" "syscall" + "time" whatsapp "github.com/Rhymen/go-whatsapp" - cron "github.com/robfig/cron/v3" godotenv "github.com/joho/godotenv" + cron "github.com/robfig/cron/v3" // "github.com/davecgh/go-spew/spew" ) type waHandler struct { c *whatsapp.Conn startTime uint64 - chats map[string]struct{} - + chats map[string]struct{} } var Schedules []Schedule @@ -34,9 +33,9 @@ var BuildCommands []BuildCommand var BuildGreetings []BuildGreeting func init() { - if err := godotenv.Load(); err != nil { - log.Print("No .env file found") - } + if err := godotenv.Load(); err != nil { + log.Print("No .env file found") + } } func main() { @@ -79,20 +78,20 @@ func main() { if !pong || err != nil { log.Fatalf("error pinging in: %v\n", err) } - + schedule := readScheduleFiles() if !schedule { log.Println("Can't read Schedule Files") return } - for index := range(Schedules) { + for index := range Schedules { phone_numbers := Schedules[index].ExpectedUsers process_name := Schedules[index].ProcessName log.Println("Read the schedule to run with cron formula " + Schedules[index].Rule) - jadwal.AddFunc(Schedules[index].Rule, func(){ + jadwal.AddFunc(Schedules[index].Rule, func() { log.Println("Run Schedule " + process_name) - for pIndex := range(phone_numbers) { + for pIndex := range phone_numbers { go sendMessage(wac, Schedules[index].Message, phone_numbers[pIndex]) } }) @@ -118,7 +117,6 @@ func main() { } func sendMessage(wac *whatsapp.Conn, message string, RJID string) { - msg := whatsapp.TextMessage{ Info: whatsapp.MessageInfo{ RemoteJid: RJID, @@ -135,13 +133,13 @@ func sendMessage(wac *whatsapp.Conn, message string, RJID string) { // if min is 2 then 2 + (50/2) = 27 // if max is 4 then 4 + (50/2) = 29 // if 29 > 5 then max is 5 - min = min + (len(kata)/2) - max = max + (len(kata)/2) + min = min + (len(kata) / 2) + max = max + (len(kata) / 2) if max > limit_max { min = limit_max - 2 max = limit_max } - waitSec := rand.Intn(max-min)+min + waitSec := rand.Intn(max-min) + min log.Printf("Randomly paused %d for throtling", waitSec) wac.Presence(RJID, whatsapp.PresenceComposing) time.Sleep(time.Duration(waitSec) * time.Second) @@ -154,6 +152,26 @@ func sendMessage(wac *whatsapp.Conn, message string, RJID string) { } } +func sendImageMessage(wac *whatsapp.Conn, img ImageMessage, RJID string) { + msg := whatsapp.ImageMessage{ + Info: whatsapp.MessageInfo{ + RemoteJid: RJID, + }, + Caption: img.Caption, + Thumbnail: img.Thumbnail, + Type: img.Type, + Content: bytes.NewReader(img.Content), + } + + wac.Presence(RJID, whatsapp.PresenceComposing) + msgId, err := wac.Send(msg) + if err != nil { + fmt.Fprintf(os.Stderr, "error sending message: %v", err) + } else { + fmt.Println("Message Sent -> ID : " + msgId) + } +} + func login(wac *whatsapp.Conn, phone_number string) error { session, err := readSession(phone_number) if err == nil { @@ -236,4 +254,4 @@ func (h *waHandler) HandleError(err error) { } else { log.Printf("error occoured: %v\n", err) } -} \ No newline at end of file +} diff --git a/parser.go b/parser.go index 119bb14..14fd5c1 100644 --- a/parser.go +++ b/parser.go @@ -3,10 +3,12 @@ package main import ( "encoding/json" "fmt" - req "github.com/imroc/req" "io/ioutil" "os" "strings" + + wa "github.com/Rhymen/go-whatsapp" + "github.com/imroc/req" ) var ( @@ -23,38 +25,85 @@ var ( errRespNotSupported = func(dst string) string { return fmt.Sprintf("[resp not supported error: %s]", dst) } ) +type ImageMessage struct { + Caption string + Thumbnail []byte // set to nil, add on your own as I have no way to test this + Type string + Content []byte +} + // nemoParser parses pesan for URL in {{url}} format // if no URL is found in pesan, pesan is returned as is // if URL is found, try POST request to url +// if response contains supported JSON, pesan will be replaced with response // currently only supports JSON response with message key -func nemoParser(pesan string, Sessions Session) (string, error) { +// if response contains image(s), image with caption will be sent instead of text +// if there are multiple URL that return images, it will send all images, only captioning the last image +// currently if duplicate URLs exist, the request will be repeated +func nemoParser(pesan string, Sessions Session) (*wa.TextMessage, map[int]ImageMessage, error) { + var countImg int + txt := &wa.TextMessage{} + mapImgs := make(map[int]ImageMessage) urlCount := strings.Count(pesan, "{{") if urlCount == 0 { - return pesan, nil + txt.Text = pesan + return txt, nil, nil } for i := 0; i < urlCount; i++ { url := between(pesan, "{{", "}}") - r, err := req.Post(url, req.BodyJSON(Sessions)) + resp, err := req.Post(url, req.BodyJSON(Sessions)) if err != nil { - pesan = strings.Replace(pesan, fmt.Sprintf("{{%s}}", url), errReqErr(url), -1) + pesan = strings.Replace(pesan, fmt.Sprintf("{{%s}}", url), errReqErr(url), 1) } - var m map[string]interface{} - r.ToJSON(&m) + // check if json/image/else + switch ct := resp.Response().Header.Get("Content-Type"); ct { + case "application/json": + var m map[string]interface{} + resp.ToJSON(&m) - // TODO maybe calls this in main to setup - sk := supportKey(os.Getenv(defSupportedRespKeysConfig)) - k := lookupKey(m, sk) - if k == "" { - pesan = strings.Replace(pesan, fmt.Sprintf("{{%s}}", url), errRespNotSupported(url), -1) - continue - } - if m[k] != "" { - pesan = strings.Replace(pesan, fmt.Sprintf("{{%s}}", url), fmt.Sprintf("%v", m[k]), -1) + // TODO maybe calls this in main to setup + sk := supportKey(os.Getenv(defSupportedRespKeysConfig)) + k := lookupKey(m, sk) + if k == "" { + pesan = strings.Replace(pesan, fmt.Sprintf("{{%s}}", url), errRespNotSupported(url), 1) + txt.Text = pesan + continue + } + if m[k] != "" { + pesan = strings.Replace(pesan, fmt.Sprintf("{{%s}}", url), fmt.Sprintf("%v", m[k]), 1) + } + // if there is image, simply remove URL + if len(mapImgs) > 0 { + continue + } + txt.Text = pesan + default: + if strings.Contains(ct, "image") { + b, err := resp.ToBytes() + if err != nil { + pesan = strings.Replace(pesan, fmt.Sprintf("{{%s}}", url), errReqErr(url), 1) + continue + } + pesan = strings.Replace(pesan, fmt.Sprintf("{{%s}}", url), "", 1) + mapImgs[countImg] = ImageMessage{ + Content: b, + Type: ct, + } + countImg++ + continue + } + pesan = strings.Replace(pesan, fmt.Sprintf("{{%s}}", url), errRespNotSupported(url), 1) } } + if len(mapImgs) > 0 { + lastImg := mapImgs[countImg-1] + lastImg.Caption = pesan + mapImgs[countImg-1] = lastImg + return nil, mapImgs, nil + } - return pesan, nil + return txt, nil, nil } // lookupKey looks up if key exists in a map based on priority @@ -68,7 +117,7 @@ func nemoParser(pesan string, Sessions Session) (string, error) { func lookupKey(m map[string]interface{}, sup map[string]int) string { var key string var prio int - for k, _ := range m { + for k := range m { for v, p := range sup { if k != v { continue diff --git a/parser_test.go b/parser_test.go index f652e00..bdf71b0 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,44 +1,101 @@ package main import ( + "bytes" "fmt" + "image" + "image/color" + "image/draw" + "image/jpeg" "os" + "reflect" "testing" + + wa "github.com/Rhymen/go-whatsapp" ) const ( - envTestURL1 = "TEST_URL_1" - envTestURL2 = "TEST_URL_2" - envTestURL3 = "TEST_URL_3" + testBaseURL = "http://127.0.0.1:6969" ) var ( - testURL1, testURL2, testURL3 string + testTextURL1 = testBaseURL + "/text/1" + testTextURL2 = testBaseURL + "/text/2" + testImageURL1 = testBaseURL + "/image/1" ) func TestNemoParser(t *testing.T) { - setupTestNemoParser(t) tests := []struct { name string pesan string - res string + txt *wa.TextMessage + imgs map[int]ImageMessage config string }{ { - name: "one_url", - pesan: fmt.Sprintf("hello {{%s}}", testURL1), - res: "hello 1", + name: "no_url", + pesan: "hello", + txt: &wa.TextMessage{ + Text: "hello", + }, + }, + { + name: "text", + pesan: fmt.Sprintf("hello {{%s}}", testTextURL1), + txt: &wa.TextMessage{ + Text: "hello 1", + }, }, { - name: "two_url", - pesan: fmt.Sprintf("hello {{%s}} hello {{%s}}", testURL1, testURL2), - res: fmt.Sprintf("hello 1 hello %s", errRespNotSupported(testURL2)), + name: "text_text", + pesan: fmt.Sprintf("hello {{%s}} hello {{%s}}", testTextURL1, testTextURL2), + txt: &wa.TextMessage{ + Text: fmt.Sprintf("hello 1 hello %s", errRespNotSupported(testTextURL2)), + }, }, { - name: "three_url_from_file", - pesan: fmt.Sprintf("hello {{%s}} hello {{%s}} hello {{%s}}", testURL1, testURL2, testURL3), - res: "hello 1 hello 2 hello 3", + name: "format_config/text_text", + pesan: fmt.Sprintf("hello {{%s}} hello {{%s}}", testTextURL1, testTextURL2), config: "config/keys-example.json", + txt: &wa.TextMessage{ + Text: "hello 1 hello 2", + }, + }, + { + name: "image", + pesan: fmt.Sprintf("{{%s}}", testImageURL1), + imgs: map[int]ImageMessage{ + 0: ImageMessage{ + Type: "image/jpeg", + Content: defaultImageBuffer(), + }, + }, + }, + { + name: "image_image", + pesan: fmt.Sprintf("hello {{%s}} image {{%s}}", testImageURL1, testImageURL1), + imgs: map[int]ImageMessage{ + 0: ImageMessage{ + Type: "image/jpeg", + Content: defaultImageBuffer(), + }, + 1: ImageMessage{ + Caption: "hello image ", + Type: "image/jpeg", + Content: defaultImageBuffer(), + }, + }, + }, + { + name: "text_image", + pesan: fmt.Sprintf("hello {{%s}} image {{%s}}", testTextURL1, testImageURL1), + imgs: map[int]ImageMessage{ + 0: ImageMessage{ + Caption: "hello 1 image ", + Type: "image/jpeg", + Content: defaultImageBuffer(), + }, + }, }, } sess := Session{ @@ -56,32 +113,40 @@ func TestNemoParser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + os.Setenv(defSupportedRespKeysConfig, "") os.Setenv(defSupportedRespKeysConfig, tt.config) - pesan, err := nemoParser(tt.pesan, sess) + txt, imgs, err := nemoParser(tt.pesan, sess) if err != nil { t.Error(err) return } - if pesan != tt.res { - t.Errorf("\nwanted:\n%s\ngot:\n%s", tt.res, pesan) + if txt != nil { + if tt.txt == nil { + t.Errorf("wanted no text, got %s text", txt.Text) + return + } + if txt.Text != tt.txt.Text { + t.Errorf("\nwanted:\n%s\ngot:\n%s", tt.txt.Text, txt.Text) + } + } + if imgs != nil { + if tt.imgs == nil { + t.Errorf("wanted no image, got %d image", len(imgs)) + } + if tt.imgs != nil && !reflect.DeepEqual(imgs, tt.imgs) { + t.Error("images don't match") + } + return } }) } } -// sets up required value here -// TODO unset env? -func setupTestNemoParser(t *testing.T) { - testURL1 = os.Getenv(envTestURL1) - if testURL1 == "" { - t.Fatalf("%s is not set", envTestURL1) - } - testURL2 = os.Getenv(envTestURL2) - if testURL2 == "" { - t.Fatalf("%s is not set", envTestURL2) - } - testURL3 = os.Getenv(envTestURL3) - if testURL3 == "" { - t.Fatalf("%s is not set", envTestURL3) - } +func defaultImageBuffer() []byte { + m := image.NewRGBA(image.Rect(0, 0, 240, 240)) + blue := color.RGBA{0, 0, 255, 255} + draw.Draw(m, m.Bounds(), &image.Uniform{blue}, image.ZP, draw.Src) + buffer := new(bytes.Buffer) + jpeg.Encode(buffer, m, nil) + return buffer.Bytes() } diff --git a/whatsappHandler.go b/whatsappHandler.go index 8ff862d..7ab97f8 100644 --- a/whatsappHandler.go +++ b/whatsappHandler.go @@ -4,18 +4,18 @@ import ( "encoding/json" "io/ioutil" - "os" "fmt" "log" - "time" - "strings" + "os" "regexp" "strconv" + "strings" + "time" whatsapp "github.com/Rhymen/go-whatsapp" ) -func (wh *waHandler) HandleImageMessage(message whatsapp.ImageMessage) { +func (wh *waHandler) HandleImageMessage(message whatsapp.ImageMessage) { if !(message.Info.Timestamp < wh.startTime) { phone_number := strings.Split(message.Info.RemoteJid, "@")[0] @@ -66,11 +66,11 @@ func (wh *waHandler) HandleImageMessage(message whatsapp.ImageMessage) { return } log.Printf("%v %v\n\timage received, saved at:%v\n", message.Info.Timestamp, message.Info.RemoteJid, filename) - + uploadS3 := AddFileToS3(filename) log.Println("Files Uploaded and here is the link : " + uploadS3) - + reply := "terminate" waktu, err := time.Parse(time.RFC3339, Sessions.Expired) @@ -92,21 +92,21 @@ func (wh *waHandler) HandleImageMessage(message whatsapp.ImageMessage) { return } - if sIndex >= (len(coral.Process.Questions)-1) { + if sIndex >= (len(coral.Process.Questions) - 1) { reply = coral.Process.EndMessage Sessions.ProcessStatus = "DONE" Sessions.Finished = time.Now().Format(time.RFC3339) - }else{ + } else { reply = coral.Process.Questions[sIndex+1].Question.Asking Sessions.ProcessStatus = "NEXT" - Sessions.CurrentQuestionSlug = sIndex+1 + Sessions.CurrentQuestionSlug = sIndex + 1 } dataBaru := Data{ - Slug: coral.Process.Questions[sIndex].Question.Slug, + Slug: coral.Process.Questions[sIndex].Question.Slug, Question: coral.Process.Questions[sIndex].Question.Asking, - Answer: uploadS3, - Created: time.Now().Format(time.RFC3339), + Answer: uploadS3, + Created: time.Now().Format(time.RFC3339), } Sessions.Datas = append(Sessions.Datas, dataBaru) @@ -149,13 +149,13 @@ func (wh *waHandler) HandleTextMessage(message whatsapp.TextMessage) { var Sessions Session // Check the existing commands - for index := range(BuildCommands) { + for index := range BuildCommands { // if the user force a new command while in the progress of session, break session and create a new one phone_number := strings.Split(message.Info.RemoteJid, "@")[0] - cur_cmd := fmt.Sprintf("%s%s", BuildCommands[index].Prefix, BuildCommands[index].Command ) + cur_cmd := fmt.Sprintf("%s%s", BuildCommands[index].Prefix, BuildCommands[index].Command) if !strings.Contains(strings.ToLower(message.Text), cur_cmd) || message.Info.Timestamp < wh.startTime { continue } @@ -166,7 +166,7 @@ func (wh *waHandler) HandleTextMessage(message whatsapp.TextMessage) { coral.getCoral(process) if len(coral.ExpectedUsers) > 0 { - for usersIndex := range(coral.ExpectedUsers) { + for usersIndex := range coral.ExpectedUsers { if coral.ExpectedUsers[usersIndex] == phone_number { break } @@ -177,14 +177,21 @@ func (wh *waHandler) HandleTextMessage(message whatsapp.TextMessage) { } } - reply, parserErr := nemoParser(BuildCommands[index].Message, Sessions) - if parserErr != nil { - log.Println(parserErr.Error()) + txt, imgs, err := nemoParser(BuildCommands[index].Message, Sessions) + if err != nil { + log.Println(err.Error()) return } - if reply != "timeout" { - go sendMessage(wh.c, reply, message.Info.RemoteJid) + // non-nil txt indicates is it a text message + if txt != nil { + go sendMessage(wh.c, txt.Text, message.Info.RemoteJid) + return + } + for i := 0; i < len(imgs); i++ { + // if order of image messages sent must follow, use synchronous + // or channel to send n-1 images, followed by the last image if all operations succeed + go sendImageMessage(wh.c, imgs[i], message.Info.RemoteJid) } time.Sleep(time.Duration(3) * time.Second) @@ -207,7 +214,7 @@ func (wh *waHandler) HandleTextMessage(message whatsapp.TextMessage) { // check the previous message who send the message, if bot, check the message, if still same, just keep silent, if not continue // if user reply then can do - + phone_number := strings.Split(message.Info.RemoteJid, "@")[0] Sessions, err := loadSession(phone_number) if err != nil { @@ -269,21 +276,21 @@ func (wh *waHandler) HandleTextMessage(message whatsapp.TextMessage) { return } - if sIndex >= (len(coral.Process.Questions)-1) { + if sIndex >= (len(coral.Process.Questions) - 1) { reply = coral.Process.EndMessage Sessions.ProcessStatus = "DONE" Sessions.Finished = time.Now().Format(time.RFC3339) - }else{ + } else { reply = coral.Process.Questions[sIndex+1].Question.Asking Sessions.ProcessStatus = "NEXT" - Sessions.CurrentQuestionSlug = sIndex+1 + Sessions.CurrentQuestionSlug = sIndex + 1 } dataBaru := Data{ - Slug: coral.Process.Questions[sIndex].Question.Slug, + Slug: coral.Process.Questions[sIndex].Question.Slug, Question: coral.Process.Questions[sIndex].Question.Asking, - Answer: message.Text, - Created: time.Now().Format(time.RFC3339), + Answer: message.Text, + Created: time.Now().Format(time.RFC3339), } Sessions.Datas = append(Sessions.Datas, dataBaru) @@ -353,14 +360,14 @@ func (wh *waHandler) HandleContactMessage(message whatsapp.ContactMessage) { } // need to test if the greeting is function well and return nothing after send message -func greeting(wac *whatsapp.Conn, RJID string, message string){ - for gIndex := range(BuildGreetings) { - for pIndex := range(BuildGreetings[gIndex].ExpectedUsers) { - if(BuildGreetings[gIndex].ExpectedUsers[pIndex] == RJID){ +func greeting(wac *whatsapp.Conn, RJID string, message string) { + for gIndex := range BuildGreetings { + for pIndex := range BuildGreetings[gIndex].ExpectedUsers { + if BuildGreetings[gIndex].ExpectedUsers[pIndex] == RJID { url := BuildGreetings[gIndex].Webhook.URL - logGreeting := LogGreeting { - Message: message, + logGreeting := LogGreeting{ + Message: message, PhoneNumber: strings.Split(RJID, "@")[0], } @@ -383,4 +390,4 @@ func greeting(wac *whatsapp.Conn, RJID string, message string){ } } } -} \ No newline at end of file +}