diff --git a/.travis.yml b/.travis.yml index 4d8ff29..ab5b8bc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,4 +6,6 @@ go: go_import_path: github.com/paked/messenger install: go get -t ./... -script: go test -v ./... +script: + - go test -v ./... + - go build ./examples/... diff --git a/README.md b/README.md index f1e7550..8cb178c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This is a Go library for making bots to be used on Facebook messenger. It is built on the [Messenger Platform](https://developers.facebook.com/docs/messenger-platform). One of the main goals of the project is to implement it in an idiomatic and easy to use fashion. -[You can find an example of how to use it here](https://github.com/paked/messenger/blob/master/cmd/bot/main.go) +You can find [examples for this library here](https://github.com/paked/messenger/blob/master/examples/). ## Tips diff --git a/actions.go b/actions.go index 53de023..bccac68 100644 --- a/actions.go +++ b/actions.go @@ -20,4 +20,7 @@ const ( OptInAction // ReferralAction represents ?ref parameter in m.me URLs ReferralAction + // AccountLinkingAction means that the event concerns changes in account linking + // status. + AccountLinkingAction ) diff --git a/cmd/bot/config.json.example b/cmd/bot/config.json.example deleted file mode 100644 index 264a73c..0000000 --- a/cmd/bot/config.json.example +++ /dev/null @@ -1,6 +0,0 @@ -{ - "verify-token": "", - "should-verify": false, - "page-token": "", - "app-secret": "" -} diff --git a/cmd/bot/main.go b/examples/basic/main.go similarity index 59% rename from cmd/bot/main.go rename to examples/basic/main.go index 4be7931..f86d6e0 100644 --- a/cmd/bot/main.go +++ b/examples/basic/main.go @@ -1,29 +1,35 @@ package main import ( + "flag" "fmt" + "log" "net/http" + "os" "time" - "github.com/paked/configure" "github.com/paked/messenger" ) var ( - conf = configure.New() - verifyToken = conf.String("verify-token", "mad-skrilla", "The token used to verify facebook") - verify = conf.Bool("should-verify", false, "Whether or not the app should verify itself") - pageToken = conf.String("page-token", "not skrilla", "The token that is used to verify the page on facebook") - appSecret = conf.String("app-secret", "", "The app secret from the facebook developer portal") - port = conf.Int("port", 8080, "The port used to serve the messenger bot") + verifyToken = flag.String("verify-token", "mad-skrilla", "The token used to verify facebook (required)") + verify = flag.Bool("should-verify", false, "Whether or not the app should verify itself") + pageToken = flag.String("page-token", "not skrilla", "The token that is used to verify the page on facebook") + appSecret = flag.String("app-secret", "", "The app secret from the facebook developer portal (required)") + host = flag.String("host", "localhost", "The host used to serve the messenger bot") + port = flag.Int("port", 8080, "The port used to serve the messenger bot") ) func main() { - conf.Use(configure.NewFlag()) - conf.Use(configure.NewEnvironment()) - conf.Use(configure.NewJSONFromFile("config.json")) + flag.Parse() - conf.Parse() + if *verifyToken == "" || *appSecret == "" || *pageToken == "" { + fmt.Println("missing arguments") + fmt.Println() + flag.Usage() + + os.Exit(-1) + } // Create a new messenger client client := messenger.New(messenger.Options{ @@ -55,7 +61,7 @@ func main() { fmt.Println("Read at:", m.Watermark().Format(time.UnixDate)) }) - fmt.Printf("Serving messenger bot on localhost:%d\n", *port) - - http.ListenAndServe(fmt.Sprintf("localhost:%d", *port), client.Handler()) + addr := fmt.Sprintf("%s:%d", *host, *port) + log.Println("Serving messenger bot on", addr) + log.Fatal(http.ListenAndServe(addr, client.Handler())) } diff --git a/examples/linked-account/main.go b/examples/linked-account/main.go new file mode 100644 index 0000000..cfde079 --- /dev/null +++ b/examples/linked-account/main.go @@ -0,0 +1,246 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "net/url" + "os" + "path" + "strings" + "time" + + "github.com/paked/messenger" +) + +const ( + webhooksPath = "/webhooks" + loginPath = "/signin" + logoutPath = "/signout" + + validUsername = "john" + validPassword = "secret" +) + +var ( + verifyToken = flag.String("verify-token", "", "The token used to verify facebook (required)") + pageToken = flag.String("page-token", "", "The token that is used to verify the page on facebook.") + appSecret = flag.String("app-secret", "", "The app secret from the facebook developer portal (required)") + host = flag.String("host", "localhost", "The host used to serve the messenger bot") + port = flag.Int("port", 8080, "The port used to serve the messenger bot") + publicHost = flag.String("public-host", "example.org", "The public facing host used to access the messenger bot") +) + +func main() { + flag.Parse() + + if *verifyToken == "" || *appSecret == "" || *pageToken == "" { + fmt.Println("missing arguments") + fmt.Println() + flag.Usage() + + os.Exit(-1) + } + + // Instantiate messenger client + client := messenger.New(messenger.Options{ + AppSecret: *appSecret, + VerifyToken: *verifyToken, + Token: *pageToken, + }) + + // Handle incoming messages + client.HandleMessage(func(m messenger.Message, r *messenger.Response) { + log.Printf("%v (Sent, %v)\n", m.Text, m.Time.Format(time.UnixDate)) + + p, err := client.ProfileByID(m.Sender.ID) + if err != nil { + log.Println("Failed to fetch user profile:", err) + } + + switch strings.ToLower(m.Text) { + case "login": + err = loginButton(r) + case "logout": + err = logoutButton(r) + case "help": + err = help(p, r) + default: + err = greeting(p, r) + } + + if err != nil { + log.Println("Failed to respond:", err) + } + }) + + // Send a feedback to the user after an update of account linking status + client.HandleAccountLinking(func(m messenger.AccountLinking, r *messenger.Response) { + var text string + switch m.Status { + case "linked": + text = "Hey there! You're now logged in :)" + case "unlinked": + text = "You've been logged out of your account." + } + + if err := r.Text(text, messenger.ResponseType); err != nil { + log.Println("Failed to send account linking feedback") + } + }) + + // Setup router + mux := http.NewServeMux() + mux.Handle(webhooksPath, client.Handler()) + mux.HandleFunc(loginPath, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + loginForm(w, r) + case "POST": + login(w, r) + } + }) + + // Listen + addr := fmt.Sprintf("%s:%d", *host, *port) + log.Println("Serving messenger bot on", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +// loginButton will present to the user a button that can be used to +// start the account linking process. +func loginButton(r *messenger.Response) error { + buttons := &[]messenger.StructuredMessageButton{ + { + Type: "account_link", + URL: "https://" + path.Join(*publicHost, loginPath), + }, + } + return r.ButtonTemplate("Link your account.", buttons, messenger.ResponseType) +} + +// logoutButton show to the user a button that can be used to start +// the process of unlinking an account. +func logoutButton(r *messenger.Response) error { + buttons := &[]messenger.StructuredMessageButton{ + { + Type: "account_unlink", + }, + } + return r.ButtonTemplate("Unlink your account.", buttons, messenger.ResponseType) +} + +// greeting salutes the user. +func greeting(p messenger.Profile, r *messenger.Response) error { + return r.Text(fmt.Sprintf("Hello, %v!", p.FirstName), messenger.ResponseType) +} + +// help displays possibles actions to the user. +func help(p messenger.Profile, r *messenger.Response) error { + text := fmt.Sprintf( + "%s, looking for actions to do? Here is what I understand.", + p.FirstName, + ) + + replies := []messenger.QuickReply{ + { + ContentType: "text", + Title: "Login", + }, + { + ContentType: "text", + Title: "Logout", + }, + } + + return r.TextWithReplies(text, replies, messenger.ResponseType) +} + +// loginForm is the endpoint responsible to displays a login +// form. During the account linking process, after clicking on the +// login button, users are directed to this form where they are +// supposed to sign into their account. When the form is submitted, +// credentials are sent to the login endpoint. +func loginForm(w http.ResponseWriter, r *http.Request) { + values := r.URL.Query() + linkingToken := values.Get("account_linking_token") + redirectURI := values.Get("redirect_uri") + fmt.Fprint(w, templateLogin(loginPath, linkingToken, redirectURI, false)) +} + +// login is the endpoint that handles the actual signing in, by +// checking the credentials, then redirecting to Facebook Messenger if +// they are valid. +func login(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + + username := r.FormValue("username") + password := r.FormValue("password") + linkingToken := r.FormValue("account_linking_token") + rawRedirect := r.FormValue("redirect_uri") + + if !checkCredentials(username, password) { + fmt.Fprint(w, templateLogin(loginPath, linkingToken, rawRedirect, true)) + return + } + + redirectURL, err := url.Parse(rawRedirect) + if err != nil { + log.Println("failed to parse url:", err) + return + } + + q := redirectURL.Query() + q.Set("authorization_code", "something") + redirectURL.RawQuery = q.Encode() + + w.Header().Set("Location", redirectURL.String()) + w.WriteHeader(http.StatusFound) +} + +func checkCredentials(username, password string) bool { + return username == validUsername && password == validPassword +} + +// templateLogin constructs the signin form. +func templateLogin(loginPath, linkingToken, redirectURI string, failed bool) string { + failedInfo := "" + if failed { + failedInfo = `

Incorrect credentials

` + } + + template := ` + + + + + +
+

Login to your account

+

+ Valid credentials are "%s" as the username and "%s" as the password +

+ %s +
+ + + + + +
+
+ + +` + return fmt.Sprintf( + template, + validUsername, + validPassword, + failedInfo, + loginPath, + linkingToken, + redirectURI, + ) +} diff --git a/message.go b/message.go index 61ee572..30a913c 100644 --- a/message.go +++ b/message.go @@ -60,6 +60,19 @@ type PostBack struct { Referral Referral `json:"referral"` } +type AccountLinking struct { + // Sender is who the message was sent from. + Sender Sender `json:"-"` + // Recipient is who the message was sent to. + Recipient Recipient `json:"-"` + // Time is when the message was sent. + Time time.Time `json:"-"` + // Status represents the new account linking status. + Status string `json:"status"` + // AuthorizationCode is a pass-through code set during the linking process. + AuthorizationCode string `json:"authorization_code"` +} + // Watermark is the RawWatermark timestamp rendered as a time.Time. func (d Delivery) Watermark() time.Time { return time.Unix(d.RawWatermark/int64(time.Microsecond), 0) diff --git a/messenger.go b/messenger.go index 208ca04..61a0b04 100644 --- a/messenger.go +++ b/messenger.go @@ -59,19 +59,24 @@ type OptInHandler func(OptIn, *Response) // ReferralHandler is a handler used postback callbacks. type ReferralHandler func(ReferralMessage, *Response) +// AccountLinkingHandler is a handler used to react to an account +// being linked or unlinked. +type AccountLinkingHandler func(AccountLinking, *Response) + // Messenger is the client which manages communication with the Messenger Platform API. type Messenger struct { - mux *http.ServeMux - messageHandlers []MessageHandler - deliveryHandlers []DeliveryHandler - readHandlers []ReadHandler - postBackHandlers []PostBackHandler - optInHandlers []OptInHandler - referralHandlers []ReferralHandler - token string - verifyHandler func(http.ResponseWriter, *http.Request) - verify bool - appSecret string + mux *http.ServeMux + messageHandlers []MessageHandler + deliveryHandlers []DeliveryHandler + readHandlers []ReadHandler + postBackHandlers []PostBackHandler + optInHandlers []OptInHandler + referralHandlers []ReferralHandler + accountLinkingHandlers []AccountLinkingHandler + token string + verifyHandler func(http.ResponseWriter, *http.Request) + verify bool + appSecret string } // New creates a new Messenger. You pass in Options in order to affect settings. @@ -131,6 +136,11 @@ func (m *Messenger) HandleReferral(f ReferralHandler) { m.referralHandlers = append(m.referralHandlers, f) } +// HandleAccountLinking adds a new AccountLinkingHandler to the Messenger +func (m *Messenger) HandleAccountLinking(f AccountLinkingHandler) { + m.accountLinkingHandlers = append(m.accountLinkingHandlers, f) +} + // Handler returns the Messenger in HTTP client form. func (m *Messenger) Handler() http.Handler { return m.mux @@ -370,6 +380,14 @@ func (m *Messenger) dispatch(r Receive) { message.Time = time.Unix(info.Timestamp/int64(time.Microsecond), 0) f(message, resp) } + case AccountLinkingAction: + for _, f := range m.accountLinkingHandlers { + message := *info.AccountLinking + message.Sender = info.Sender + message.Recipient = info.Recipient + message.Time = time.Unix(info.Timestamp/int64(time.Microsecond), 0) + f(message, resp) + } } } } @@ -431,6 +449,8 @@ func (m *Messenger) classify(info MessageInfo, e Entry) Action { return OptInAction } else if info.ReferralMessage != nil { return ReferralAction + } else if info.AccountLinking != nil { + return AccountLinkingAction } return UnknownAction } diff --git a/receiving.go b/receiving.go index 3a198c3..96185a2 100644 --- a/receiving.go +++ b/receiving.go @@ -43,6 +43,8 @@ type MessageInfo struct { OptIn *OptIn `json:"optin"` ReferralMessage *ReferralMessage `json:"referral"` + + AccountLinking *AccountLinking `json:"account_linking"` } type OptIn struct {