From da77b9ec841f8ce3688bf1ebfd2b33b154f29038 Mon Sep 17 00:00:00 2001 From: Sujay Bawaskar <42200964+sbawaskar@users.noreply.github.com> Date: Sat, 13 Mar 2021 18:21:30 +0530 Subject: [PATCH] Added support for activity update (#51) * Added support for activity update * Update samples/activity-update/README.md Co-authored-by: Prasad Ghangal --- .gitignore | 1 + connector/client/client.go | 20 ++++- core/activity/response.go | 23 +++++- core/bot_framework_adapter.go | 11 +++ core/bot_framework_adapter_test.go | 73 ++++++++++++++++- samples/activity-update/README.md | 116 ++++++++++++++++++++++++++ samples/activity-update/main.go | 126 +++++++++++++++++++++++++++++ 7 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 samples/activity-update/README.md create mode 100644 samples/activity-update/main.go diff --git a/.gitignore b/.gitignore index f1c181e..cf63a61 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out +**/.vscode/** \ No newline at end of file diff --git a/connector/client/client.go b/connector/client/client.go index e4d473c..acaaca4 100644 --- a/connector/client/client.go +++ b/connector/client/client.go @@ -40,6 +40,7 @@ import ( type Client interface { Post(url url.URL, activity schema.Activity) error Delete(url url.URL, activity schema.Activity) error + Put(url url.URL, activity schema.Activity) error } // ConnectorClient implements Client to send HTTP requests to the connector service. @@ -67,7 +68,7 @@ func (client *ConnectorClient) Post(target url.URL, activity schema.Activity) er if err != nil { return err } - + fmt.Println(target.String()) req, err := http.NewRequest(http.MethodPost, target.String(), bytes.NewBuffer(jsonStr)) if err != nil { return err @@ -87,6 +88,22 @@ func (client *ConnectorClient) Delete(target url.URL, activity schema.Activity) return client.sendRequest(req, activity) } +// Put an activity. +// +// Creates a HTTP PUT request with the provided activity payload and a Bearer token in the header. +// Returns any error as received from the call to connector service. +func (client *ConnectorClient) Put(target url.URL, activity schema.Activity) error { + jsonStr, err := json.Marshal(activity) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPut, target.String(), bytes.NewBuffer(jsonStr)) + if err != nil { + return err + } + return client.sendRequest(req, activity) +} + func (client *ConnectorClient) sendRequest(req *http.Request, activity schema.Activity) error { token, err := client.getToken() if err != nil { @@ -109,7 +126,6 @@ func (client *ConnectorClient) checkRespError(resp *http.Response, err error) er } } defer resp.Body.Close() - // Check if resp allowed for _, code := range allowedResp { if code == resp.StatusCode { diff --git a/core/activity/response.go b/core/activity/response.go index 534f37b..cd8e99a 100644 --- a/core/activity/response.go +++ b/core/activity/response.go @@ -33,6 +33,7 @@ import ( type Response interface { SendActivity(activity schema.Activity) error DeleteActivity(activity schema.Activity) error + UpdateActivity(activity schema.Activity) error } const ( @@ -40,8 +41,7 @@ const ( APIVersion = "v3" sendToConversationURL = "/%s/conversations/%s/activities" - replyToActivityURL = "/%s/conversations/%s/activities/%s" - deleteActivityURL = "/%s/conversations/%s/activities/%s" + activityResourceURL = "/%s/conversations/%s/activities/%s" ) // DefaultResponse is the default implementation of Response. @@ -56,7 +56,7 @@ func (response *DefaultResponse) DeleteActivity(activity schema.Activity) error return errors.Wrapf(err, "Failed to parse ServiceURL %s.", activity.ServiceURL) } - respPath := fmt.Sprintf(deleteActivityURL, APIVersion, activity.Conversation.ID, activity.ID) + respPath := fmt.Sprintf(activityResourceURL, APIVersion, activity.Conversation.ID, activity.ID) // Send activity to client u.Path = path.Join(u.Path, respPath) @@ -75,7 +75,7 @@ func (response *DefaultResponse) SendActivity(activity schema.Activity) error { // if ReplyToID is set in the activity, we send reply to that particular activity if activity.ReplyToID != "" { - respPath = fmt.Sprintf(replyToActivityURL, APIVersion, activity.Conversation.ID, activity.ID) + respPath = fmt.Sprintf(activityResourceURL, APIVersion, activity.Conversation.ID, activity.ID) } // Send activity to client @@ -84,6 +84,21 @@ func (response *DefaultResponse) SendActivity(activity schema.Activity) error { return errors.Wrap(err, "Failed to send response.") } +// UpdateActivity sends a Put activity method to the BOT connector service. +func (response *DefaultResponse) UpdateActivity(activity schema.Activity) error { + u, err := url.Parse(activity.ServiceURL) + if err != nil { + return errors.Wrapf(err, "Failed to parse ServiceURL %s.", activity.ServiceURL) + } + + respPath := fmt.Sprintf(activityResourceURL, APIVersion, activity.Conversation.ID, activity.ID) + + // Send activity to client + u.Path = path.Join(u.Path, respPath) + err = response.Client.Put(*u, activity) + return errors.Wrap(err, "Failed to update response.") +} + // NewActivityResponse provides a DefaultResponse implementaton of Response. func NewActivityResponse(connectorClient client.Client) (Response, error) { if connectorClient == nil { diff --git a/core/bot_framework_adapter.go b/core/bot_framework_adapter.go index 46a98dd..a079dac 100644 --- a/core/bot_framework_adapter.go +++ b/core/bot_framework_adapter.go @@ -38,6 +38,7 @@ type Adapter interface { ProcessActivity(ctx context.Context, req schema.Activity, handler activity.Handler) error ProactiveMessage(ctx context.Context, ref schema.ConversationReference, handler activity.Handler) error DeleteActivity(ctx context.Context, activityID string, ref schema.ConversationReference) error + UpdateActivity(ctx context.Context, activity schema.Activity) error } // AdapterSetting is the configuration for the Adapter. @@ -155,3 +156,13 @@ func (bf *BotFrameworkAdapter) authenticateRequest(ctx context.Context, req sche return errors.Wrap(err, "Authentication failed.") } + +// UpdateActivity Updates an existing activity +func (bf *BotFrameworkAdapter) UpdateActivity(ctx context.Context, req schema.Activity) error { + response, err := activity.NewActivityResponse(bf.Client) + + if err != nil { + return errors.Wrap(err, "Failed to create response object.") + } + return response.UpdateActivity(req) +} diff --git a/core/bot_framework_adapter_test.go b/core/bot_framework_adapter_test.go index 77a137a..6e80657 100644 --- a/core/bot_framework_adapter_test.go +++ b/core/bot_framework_adapter_test.go @@ -37,10 +37,11 @@ import ( "github.com/stretchr/testify/assert" ) -func serverMock() *httptest.Server { +func serverMock(t *testing.T) *httptest.Server { handler := http.NewServeMux() handler.HandleFunc("/v3/conversations/abcd1234/activities", msTeamsMockMock) - + h1 := &msTeamsActivityUpdateMock{t: t} + handler.Handle("/v3/conversations/TestActivityUpdate/activities", h1) srv := httptest.NewServer(handler) return srv @@ -50,6 +51,19 @@ func msTeamsMockMock(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("{\"id\":\"1\"}")) } +type msTeamsActivityUpdateMock struct { + t *testing.T +} + +func (th *msTeamsActivityUpdateMock) ServeHTTP(w http.ResponseWriter, r *http.Request) { + assert.Equal(th.t, "PUT", r.Method, "Expect PUT method") + activity := schema.Activity{} + err := json.NewDecoder(r.Body).Decode(&activity) + assert.Equal(th.t, "TestLabel", activity.Label, "Expect PUT method") + assert.Nil(th.t, err, fmt.Sprintf("Failed with error %s", err)) + _, _ = w.Write([]byte("{\"id\":\"1\"}")) +} + // Create a handler that defines operations to be performed on respective events. // Following defines the operation to be performed on the 'message' event. var customHandler = activity.HandlerFuncs{ @@ -59,7 +73,7 @@ var customHandler = activity.HandlerFuncs{ } func TestExample(t *testing.T) { - srv := serverMock() + srv := serverMock(t) // activity depicts a request as received from a client activity := schema.Activity{ Type: schema.Message, @@ -110,3 +124,56 @@ func TestExample(t *testing.T) { handler.ServeHTTP(rr, req) assert.Equal(t, rr.Code, 200, "Expect 200 response status") } + +func TestActivityUpdate(t *testing.T) { + srv := serverMock(t) + + activity := schema.Activity{ + Type: schema.Message, + From: schema.ChannelAccount{ + ID: "12345678", + Name: "Pepper's News Feed", + }, + Conversation: schema.ConversationAccount{ + ID: "TestActivityUpdate", + Name: "Convo1", + }, + Recipient: schema.ChannelAccount{ + ID: "1234abcd", + Name: "SteveW", + }, + Text: "Message from Teams Client", + ReplyToID: "5d5cdc723", + ServiceURL: srv.URL, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ctx := context.Background() + setting := core.AdapterSetting{ + AppID: "asdasd", + AppPassword: "cfg.MicrosoftTeams.AppPassword", + } + setting.CredentialProvider = auth.SimpleCredentialProvider{ + AppID: setting.AppID, + Password: setting.AppPassword, + } + clientConfig, err := client.NewClientConfig(setting.CredentialProvider, auth.ToChannelFromBotLoginURL[0]) + assert.Nil(t, err, fmt.Sprintf("Failed with error %s", err)) + connectorClient, err := client.NewClient(clientConfig) + assert.Nil(t, err, fmt.Sprintf("Failed with error %s", err)) + adapter := core.BotFrameworkAdapter{setting, &core.MockTokenValidator{}, connectorClient} + act, err := adapter.ParseRequest(ctx, req) + act.Label = "TestLabel" + err = adapter.UpdateActivity(ctx, act) + assert.Nil(t, err, fmt.Sprintf("Failed with error %s", err)) + }) + rr := httptest.NewRecorder() + bodyJSON, err := json.Marshal(activity) + assert.Nil(t, err, fmt.Sprintf("Failed with error %s", err)) + bodyBytes := bytes.NewReader(bodyJSON) + req, err := http.NewRequest(http.MethodPost, "/api/messages", bodyBytes) + assert.Nil(t, err, fmt.Sprintf("Failed with error %s", err)) + req.Header.Set("Authorization", "Bearer abc123") + handler.ServeHTTP(rr, req) + assert.Equal(t, rr.Code, 200, "Expect 200 response status") +} diff --git a/samples/activity-update/README.md b/samples/activity-update/README.md new file mode 100644 index 0000000..768d270 --- /dev/null +++ b/samples/activity-update/README.md @@ -0,0 +1,116 @@ +# Bot Framework activity update sample. + +This Microsoft Teams bot uses the [msbotbuilder-go](https://github.com/infracloudio/msbotbuilder-go) library. It shows how to create a simple bot that accepts input from the user and echoes response with an attachment. + +## Run the example + +#### Step 1: Register MS BOT + +Follow the official [documentation](https://docs.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-for-teams#register-your-web-service-with-the-bot-framework) to create and register your BOT. + +Copy `APP_ID` and `APP_PASSWORD` generated for your BOT. + +Do not set any messaging endpoint for now. + +#### Step 2: Run echo local server + +Set two variables for the session as `APP_ID` and `APP_PASSWORD` to the values of your BotFramework `APP_ID` and `APP_PASSWORD`. Then run the `main.go` file. + +```bash +export APP_PASSWORD=MICROSOFT_APP_PASSWORD +export APP_ID=MICROSOFT_APP_ID + +go run main.go +``` + +This will start a server which will listen on port `3978` + +#### Step 3: Expose local server with ngrok + +Now, in separate terminal, run [ngrok](https://ngrok.com/download) command to expose your local server to outside world. + +```sh +$ ngrok http 3978 +``` + +Copy `https` endpoint, go to [Bot Framework](https://dev.botframework.com/bots) dashboard and set messaging endpoint under Settings. + +#### Step 4: Test the BOT + +You can either test BOT on BotFramework portal or you can create app manifest and install the App on Teams as mentioned [here](https://docs.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-for-teams#create-your-app-manifest-and-package). + + +## Understanding the example + +The program starts by creating a handler struct of type `activity.HandlerFuncs`. + +This struct contains a definition for the `OnMessageFunc` field which is treated as a callback by the library on the respective event. +In this sample, when the GetCard command is issued from the chat application, an adaptive card with an input box is sent from this bot. Once submit button on the card is clicked the card is deleted. Also, the bot updates the activity and sets the text message to activity as "Changed Activity" after the card is deleted. + +```bash +var customHandler = activity.HandlerFuncs{ + OnMessageFunc: func(turn *activity.TurnContext) (schema.Activity, error) { + if turn.Activity.Text == "getCard" { + sJson := `{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "text": "Sample" + }, + { + "type": "Input.Text", + "id": "firstName", + "placeholder": "What is your first name?" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit" + } + ] + }` + var obj map[string]interface{} + err := json.Unmarshal(([]byte(sJson)), &obj) + if err != nil { + return schema.Activity{}, nil + } + attachments := []schema.Attachment{ + { + ContentType: "application/vnd.microsoft.card.adaptive", + Content: obj, + }, + } + return turn.SendActivity(activity.MsgOptionAttachments(attachments)) + } + if turn.Activity.Value != nil { + fmt.Println("Activity=", turn.Activity.Value) + activityID = turn.Activity.ReplyToID + } + + return turn.SendActivity(activity.MsgOptionText("Echo: " + turn.Activity.Text)) + }, +} +``` + + +The `init` function picks up the `APP_ID` and `APP_PASSWORD` from the environment session and creates an `adapter` using this. + + +A webserver is started with a handler which passes the received payload to `adapter.ParseRequest`. This methods authenticates the payload, parses the request and returns an Activity value. + +``` +activity, err := adapter.ParseRequest(ctx, req) +``` + + +The Activity is then passed to `adapter.ProcessActivity` with the handler created to process the activity as per the handler functions and send the response to the connector service. + +``` +err = adapter.ProcessActivity(ctx, activity, customHandler) +``` + +In case of no error, this web server responds with a 200 status. diff --git a/samples/activity-update/main.go b/samples/activity-update/main.go new file mode 100644 index 0000000..872db0b --- /dev/null +++ b/samples/activity-update/main.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + + "github.com/infracloudio/msbotbuilder-go/core" + "github.com/infracloudio/msbotbuilder-go/core/activity" + "github.com/infracloudio/msbotbuilder-go/schema" +) + +var customHandler = activity.HandlerFuncs{ + OnMessageFunc: func(turn *activity.TurnContext) (schema.Activity, error) { + if turn.Activity.Text == "getCard" { + sJSON := `{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "text": "Sample" + }, + { + "type": "Input.Text", + "id": "firstName", + "placeholder": "What is your first name?" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit" + } + ] + }` + var obj map[string]interface{} + err := json.Unmarshal(([]byte(sJSON)), &obj) + if err != nil { + return schema.Activity{}, nil + } + attachments := []schema.Attachment{ + { + ContentType: "application/vnd.microsoft.card.adaptive", + Content: obj, + }, + } + return turn.SendActivity(activity.MsgOptionAttachments(attachments)) + } + if turn.Activity.Value != nil { + fmt.Println("Activity=", turn.Activity.Value) + activityID = turn.Activity.ReplyToID + } + + return turn.SendActivity(activity.MsgOptionText("Echo: " + turn.Activity.Text)) + }, +} + +var activityID string + +// HTTPHandler handles the HTTP requests from then connector service +type HTTPHandler struct { + core.Adapter +} + +func (ht *HTTPHandler) processMessage(w http.ResponseWriter, req *http.Request) { + + ctx := context.Background() + activityInstance, err := ht.Adapter.ParseRequest(ctx, req) + if err != nil { + fmt.Println("Failed to parse request.", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + err = ht.Adapter.ProcessActivity(ctx, activityInstance, customHandler) + if err != nil { + fmt.Println("Failed to process request", err) + http.Error(w, err.Error(), http.StatusBadRequest) + fmt.Println(err.Error()) + return + } + conversationRef := activity.GetCoversationReference(activityInstance) + act := activity.ApplyConversationReference(schema.Activity{Type: schema.Message}, conversationRef, true) + if activityID != "" { + act.Text = "Changed Activity" + act.ID = activityID + err = ht.Adapter.UpdateActivity(ctx, act) + if err != nil { + fmt.Println("Failed to process request", err) + http.Error(w, err.Error(), http.StatusBadRequest) + fmt.Println(err.Error()) + return + } + activityID = conversationRef.ActivityID + } else { + activityID = conversationRef.ActivityID + } + fmt.Println("Request processed successfully.") +} + +func main() { + + setting := core.AdapterSetting{ + AppID: os.Getenv("APP_ID"), + AppPassword: os.Getenv("APP_PASSWORD"), + } + + adapter, err := core.NewBotAdapter(setting) + if err != nil { + log.Fatal("Error creating adapter: ", err) + } + + httpHandler := &HTTPHandler{adapter} + + http.HandleFunc("/api/messages", httpHandler.processMessage) + fmt.Println("Starting server on port:8080...") + err = http.ListenAndServe(":8080", nil) + if err != nil { + log.Fatal("Error creating adapter: ", err) + } +}