Merge pull request #29 from MSMeMend/master
Optional authorization and cards for Hubot used with MS Teams
This commit is contained in:
Коммит
a42662c1d2
67
README.md
67
README.md
|
@ -3,7 +3,22 @@
|
|||
[![npm version](https://badge.fury.io/js/hubot-botframework.svg)](https://badge.fury.io/js/hubot-botframework) [![Build Status](https://travis-ci.org/Microsoft/BotFramework-Hubot.svg?branch=master)](https://travis-ci.org/Microsoft/BotFramework-Hubot) [![Coverage Status](https://coveralls.io/repos/github/Microsoft/BotFramework-Hubot/badge.svg?branch=master)](https://coveralls.io/github/Microsoft/BotFramework-Hubot?branch=master)
|
||||
|
||||
# Installation
|
||||
Install `hubot`. Make sure to `npm install --save hubot-botframework` to add this module. Run the command `./bin/hubot -a botframework` to run the bot from your local computer.
|
||||
### Use hubot in Bot Framework Supported Channels
|
||||
1. Install `hubot`. Make sure to `npm install --save hubot-botframework` to add this module.
|
||||
|
||||
2. Create a Botframework Registration by completing the [Bot Registration Page](https://dev.botframework.com/bots/new). Store the created app id and app password for use later.
|
||||
|
||||
3. Configure the required environment variables, and run the command `./bin/hubot -a botframework` to run the bot from your local computer.
|
||||
|
||||
You can then interact with your hubot through any Bot Framework supported channel.
|
||||
|
||||
### Additional Steps to Use Hubot in [Microsoft Teams](https://products.office.com/en-US/microsoft-teams/)
|
||||
|
||||
4. Create a Microsoft Teams app package (.zip) to upload in Teams. We recommend using the manifest editor in [App Studio for Microsoft Teams](https://docs.microsoft.com/en-us/microsoftteams/platform/get-started/get-started-app-studio). Include the bot's app id and password in the bots section.
|
||||
|
||||
5. In Microsoft Teams, navigate to the Store and select `Upload a custom app`. Select the zipped Teams App Package, and install the bot for personal and/or team use.
|
||||
|
||||
You can then interact with hubot through a personal chat or by @mentioning the name of the uploaded custom app in a Team. In personal chats, the bot's name can be dropped from messages(`ping` or `hubot ping`). In Teams, @mention the bot and omit the bot's name from the command (`@myhubot ping`).
|
||||
|
||||
# Global Variables
|
||||
You can configure the Hubot BotFramework adapter through environment variables.
|
||||
|
@ -13,14 +28,58 @@ Required (obtained from the BotFramework portal):
|
|||
2. `BOTBUILDER_APP_PASSWORD` - This is the secret for your bot.
|
||||
|
||||
Optional:
|
||||
1. `BOTBUILDER_ENDPOINT` - Sets a custom HTTP endpoint for your bot to receive messages on (defualt is `/api/messages`).
|
||||
1. `BOTBUILDER_ENDPOINT` - Sets a custom HTTP endpoint for your bot to receive messages on (default is `/api/messages`).
|
||||
|
||||
2. `HUBOT_TEAMS_ENABLE_AUTH` - When set to `true`, restricts sending commands to hubot to a specific set of users in Teams. Messages from all non-Teams channels are blocked. Authorization is disabled by default.
|
||||
|
||||
3. `HUBOT_TEAMS_INITIAL_ADMINS` - Required if `HUBOT_TEAMS_ENABLE_AUTH` is true. A comma-separated list of user principal names ([UPNs](https://docs.microsoft.com/en-us/windows/desktop/ADSchema/a-userprincipalname)). The users on this list will be admins and able to send commands to hubot when the hubot is first run with authorization enabled.
|
||||
|
||||
# Channel Specific Variables
|
||||
## [Microsoft Teams](https://products.office.com/en-US/microsoft-teams/)
|
||||
### [Microsoft Teams](https://products.office.com/en-US/microsoft-teams/)
|
||||
These variables will only take effect if a user communicates with your hubot through [Microsoft Teams](https://products.office.com/en-US/microsoft-teams/).
|
||||
|
||||
Optional:
|
||||
1. `HUBOT_OFFICE365_TENANT_FILTER` - Comma seperated list of Office365 tenant Ids that are allowed to communicate with your hubot. By default ALL Office365 tenants can communicate with your hubot if they sideload your application manifest.
|
||||
|
||||
# Optional Authorization for Microsoft Teams:
|
||||
|
||||
**NOTE:** The UPNs used for authorization are stored in the hubot brain, so brain persistence affects the use of `HUBOT_TEAMS_INITIAL_ADMINS` as described below.
|
||||
|
||||
Authorization restricts the users that can send commands to hubot to a defined set of Microsoft Teams users. Authorization is currently only supported for the Teams channel, so when enabled, messages from all other channels are blocked. To maximize back compatibility, authorization is disabled by default and must be enabled to be used.
|
||||
|
||||
### Configuring authorization
|
||||
Authorization is set up using the `HUBOT_TEAMS_ENABLE_AUTH` and `HUBOT_TEAMS_INITIAL_ADMINS` environment variables.
|
||||
|
||||
* `HUBOT_TEAMS_ENABLE_AUTH` controls whether authorization is enabled or not. If the variable is not set, authorization is disabled. To enable authorization, set the environment variable to `true`.
|
||||
|
||||
* `HUBOT_TEAMS_INITIAL_ADMINS` is required if authorization is enabled. This variable contains a comma-separated list of UPNs. When the hubot is run with authorization enabled for the first time, the users whose UPNs are listed will be admins and authorized to send commands to hubot. These UPNs are stored in the hubot brain. After running hubot with authorization enabled for the first time:
|
||||
|
||||
- If your hubot brain is persistent, to change the list of authorized users, first delete the stored list of authorized users from your hubot's brain then change `HUBOT_TEAMS_INITIAL_ADMINS` to the new list. Also consider using the [hubot-msteams](https://github.com/jayongg/TeamsHubot) script package to dynamically control authorizations.
|
||||
|
||||
- If your hubot brain isn't persistent, the `HUBOT_TEAMS_INITIAL_ADMINS` list will be used to set admins every time hubot is restarted.
|
||||
|
||||
# Card-based Interactions for Microsoft Teams
|
||||
|
||||
**Add screenshots (create an images folder to store them in)**
|
||||
|
||||
Hubot is great, but hubot without needing to type in whole commands and with less typos is even better. Card-based interactions wrap hubot responses into cards and provide buttons on the card containing useful follow-up commands. To run a follow-up command, simply click the button with the command. If user input is needed, another card is shown with fields for input, and the rest of the command is constructed for you.
|
||||
|
||||
Currently, card based interactions are supported for the [hubot-github](https://github.com/hydal/hubot-github) package.
|
||||
|
||||
### Defining new card-based interactions
|
||||
|
||||
Adding new card-based interactions has two steps:
|
||||
|
||||
1. Add entries to HubotResponseCards located in `src/hubot-response-cards.coffee`. Each entry is from a regex to an array of follow up commands.
|
||||
* The regex should map to the command that you want to generate a card for with wildcards for the hubot's name and regexes for each user input. See the `hubot-github` entries for examples.
|
||||
* The follow up queries should match the key for the follow up command in HubotQueryParts.
|
||||
|
||||
2. Add entries to HubotQueryParts located in `src/hubot-query-parts.coffee`. Each entry is from the command to two arrays containing the text and input parts of a command. These arrays are used to construct the query with any user inputs to send to hubot.
|
||||
* textParts contains the text surrounding any user inputs, if a command has no user input, it contains one string in textParts. Note that the first entry of textParts starts with 'hubot'
|
||||
* inputParts contains representations of each user input in a command, if any. The text is used to prompt the user for input.
|
||||
A special syntax can used for inputs with finite choices to create a dropdown selector. In this case, a / is used followed by the choices separated by the word " or ". See the `hubot-github` entries for examples.
|
||||
|
||||
Once these entries have been added, cards with follow up commands will be generated for the commands added to HubotResponseCards. For menu cards used to initiate card-based interactions for any command in a script library, use the [hubot-msteams](https://github.com/jayongg/TeamsHubot) library.
|
||||
|
||||
# Contributing
|
||||
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comm
|
||||
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments
|
||||
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "hubot-botframework",
|
||||
"version": "0.10.1",
|
||||
"version": "0.12.0-alpha1",
|
||||
"description": "Bot Framework adapter for Hubot",
|
||||
"main": "./src/adapter.coffee",
|
||||
"author": "Microsoft Corp.",
|
||||
|
@ -18,6 +18,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"botbuilder": ">=3.5.0",
|
||||
"botbuilder-teams": ">=0.2.1",
|
||||
"parent-require": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
@ -31,6 +32,7 @@
|
|||
"hubot": "^2.19.0",
|
||||
"istanbul": "^0.4.5",
|
||||
"mocha": "^3.5.0",
|
||||
"mocha-lcov-reporter": "^1.3.0"
|
||||
"mocha-lcov-reporter": "^1.3.0",
|
||||
"rewiremock": "^3.7.7"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,8 +11,10 @@
|
|||
LogPrefix = "hubot-botframework-middleware:"
|
||||
|
||||
class BaseMiddleware
|
||||
constructor: (@robot) ->
|
||||
constructor: (@robot, appId, appPassword) ->
|
||||
@robot.logger.info "#{LogPrefix} creating middleware..."
|
||||
@appId = appId
|
||||
@appPassword = appPassword
|
||||
|
||||
toReceivable: (activity) ->
|
||||
throw new Error('toReceivable not implemented')
|
||||
|
@ -21,6 +23,10 @@ class BaseMiddleware
|
|||
throw new Error('toSendable not implemented')
|
||||
|
||||
class TextMiddleware extends BaseMiddleware
|
||||
# TextMiddleware doesn't use invokes currently, so just return null
|
||||
handleInvoke: (invokeEvent, connector) ->
|
||||
return null
|
||||
|
||||
toReceivable: (activity) ->
|
||||
@robot.logger.info "#{LogPrefix} TextMiddleware toReceivable"
|
||||
address = activity.address
|
||||
|
@ -42,6 +48,39 @@ class TextMiddleware extends BaseMiddleware
|
|||
}
|
||||
|
||||
return message
|
||||
|
||||
# Constructs a text message response to indicate an error to the user in the
|
||||
# message channel they are using
|
||||
constructErrorResponse: (activity, text) ->
|
||||
payload =
|
||||
type: 'message'
|
||||
text: "#{text}"
|
||||
address: activity?.address
|
||||
return payload
|
||||
|
||||
# Sends an error message back to the user if authorization isn't supported for the
|
||||
# channel or prepares and sends the message to hubot for reception
|
||||
maybeReceive: (activity, connector, authEnabled) ->
|
||||
# Return an error to the user if the message channel doesn't support authorization
|
||||
# and authorization is enabled
|
||||
if authEnabled
|
||||
@robot.logger.info "#{LogPrefix} Authorization isn\'t supported
|
||||
for the channel error"
|
||||
text = "Authorization isn't supported for this channel"
|
||||
payload = @constructErrorResponse(activity, text)
|
||||
@send(connector, payload)
|
||||
else
|
||||
event = @toReceivable activity
|
||||
if event?
|
||||
@robot.receive event
|
||||
|
||||
# Sends the payload to the bot framework messaging channel
|
||||
send: (connector, payload) ->
|
||||
if !Array.isArray(payload)
|
||||
payload = [payload]
|
||||
connector.send payload, (err, _) ->
|
||||
if err
|
||||
throw err
|
||||
|
||||
Middleware = {
|
||||
'*': TextMiddleware
|
||||
|
|
|
@ -24,8 +24,26 @@ class BotFrameworkAdapter extends Adapter
|
|||
@appId = process.env.BOTBUILDER_APP_ID
|
||||
@appPassword = process.env.BOTBUILDER_APP_PASSWORD
|
||||
@endpoint = process.env.BOTBUILDER_ENDPOINT || "/api/messages"
|
||||
@enableAuth = false
|
||||
if process.env.HUBOT_TEAMS_ENABLE_AUTH? and process.env.HUBOT_TEAMS_ENABLE_AUTH == 'true'
|
||||
@enableAuth = true
|
||||
@initialAdmins = process.env.HUBOT_TEAMS_INITIAL_ADMINS
|
||||
robot.logger.info "#{LogPrefix} Adapter loaded. Using appId #{@appId}"
|
||||
|
||||
# Initial Admins should be required when auth is enabled
|
||||
if @enableAuth
|
||||
if @initialAdmins?
|
||||
# If there isn't a list of authorized users in the brain, populate
|
||||
# it with admins from the environment variable
|
||||
if robot.brain.get("authorizedUsers") is null
|
||||
robot.logger.info "#{LogPrefix} Restricting by name, setting admins"
|
||||
authorizedUsers = {}
|
||||
for admin in process.env.HUBOT_TEAMS_INITIAL_ADMINS.split(",")
|
||||
authorizedUsers[admin.toLowerCase()] = true
|
||||
robot.brain.set("authorizedUsers", authorizedUsers)
|
||||
else
|
||||
throw new Error("HUBOT_TEAMS_INITIAL_ADMINS is required for authorization")
|
||||
|
||||
@connector = new BotBuilder.ChatConnector {
|
||||
appId: @appId
|
||||
appPassword: @appPassword
|
||||
|
@ -33,9 +51,19 @@ class BotFrameworkAdapter extends Adapter
|
|||
|
||||
@connector.onEvent (events, cb) => @onBotEvents events, cb
|
||||
|
||||
@connector.onInvoke (events, cb) => @onInvoke events, cb
|
||||
|
||||
|
||||
# Handles the invoke and passes an event to be handled, if needed
|
||||
onInvoke: (invokeEvent, cb) ->
|
||||
middleware = @using(invokeEvent.source)
|
||||
event = middleware.handleInvoke(invokeEvent, @connector)
|
||||
if event != null
|
||||
@handleActivity(event)
|
||||
|
||||
using: (name) ->
|
||||
MiddlewareClass = Middleware.middlewareFor(name)
|
||||
new MiddlewareClass(@robot)
|
||||
new MiddlewareClass(@robot, @appId, @appPassword)
|
||||
|
||||
onBotEvents: (activities, cb) ->
|
||||
@robot.logger.info "#{LogPrefix} onBotEvents"
|
||||
|
@ -43,10 +71,12 @@ class BotFrameworkAdapter extends Adapter
|
|||
@handleActivity activity for activity in activities
|
||||
|
||||
handleActivity: (activity) ->
|
||||
@robot.logger.info "#{LogPrefix} Handling activity Channel: #{activity.source}; type: #{activity.type}"
|
||||
event = @using(activity.source).toReceivable(activity)
|
||||
if event?
|
||||
@robot.receive event
|
||||
@robot.logger.info "#{LogPrefix} Handling activity Channel:
|
||||
#{activity.source}; type: #{activity.type}"
|
||||
|
||||
# Construct the middleware
|
||||
middleware = @using(activity.source)
|
||||
middleware.maybeReceive(activity, @connector, @enableAuth)
|
||||
|
||||
send: (context, messages...) ->
|
||||
@robot.logger.info "#{LogPrefix} send"
|
||||
|
@ -54,15 +84,12 @@ class BotFrameworkAdapter extends Adapter
|
|||
|
||||
reply: (context, messages...) ->
|
||||
@robot.logger.info "#{LogPrefix} reply"
|
||||
|
||||
for msg in messages
|
||||
activity = context.user.activity
|
||||
payload = @using(activity.source).toSendable(context, msg)
|
||||
if !Array.isArray(payload)
|
||||
payload = [payload]
|
||||
@connector.send payload, (err, _) ->
|
||||
if err
|
||||
throw err
|
||||
|
||||
middleware = @using(activity.source)
|
||||
payload = middleware.toSendable(context, msg)
|
||||
middleware.send(@connector, payload)
|
||||
|
||||
run: ->
|
||||
@robot.router.post @endpoint, @connector.listen()
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
# A data structure used for constructing follow up commands in cards
|
||||
# created for hubot repsonses sent to Microsoft Teams. Separates hubot
|
||||
# queries into text parts and user provided input parts. The '/'
|
||||
# character is used to indicate inputs with finite choices rather than
|
||||
# accepting the input as a text field.
|
||||
# Only queries used as a follow up query in the HubotResponseCards data
|
||||
# structure are included, not all hubot commands.
|
||||
|
||||
HubotQueryParts = {
|
||||
"gho":
|
||||
"textParts": [
|
||||
"hubot gho"
|
||||
]
|
||||
"gho list (teams|repos|members)":
|
||||
"textParts": [
|
||||
"hubot gho list "
|
||||
]
|
||||
"inputParts": [
|
||||
"List what?/teams or repos or members"
|
||||
]
|
||||
"gho list public repos":
|
||||
"textParts": [
|
||||
"hubot gho list public repos"
|
||||
]
|
||||
"gho create team <team name>":
|
||||
"textParts": [
|
||||
"hubot gho create team "
|
||||
]
|
||||
"inputParts": [
|
||||
"What is the name of the team to create? (Max 1024 characters)"
|
||||
]
|
||||
"gho create repo <repo name>/<private|public>":
|
||||
"textParts": [
|
||||
"hubot gho create repo ",
|
||||
"/"
|
||||
]
|
||||
"inputParts": [
|
||||
"What is the name of the repo to create? (Max 1024 characters)",
|
||||
"Public or private?/public or private"
|
||||
]
|
||||
"gho add (members|repos) <members|repos> to team <team name>":
|
||||
"textParts": [
|
||||
"hubot gho add ",
|
||||
" ",
|
||||
" to team "
|
||||
]
|
||||
"inputParts": [
|
||||
"Add members or repos?/members or repos",
|
||||
"Input a comma separated list to add",
|
||||
"What is the name of the team to add to?"
|
||||
]
|
||||
"gho remove (repos|members) <members|repos> from team <team name>":
|
||||
"textParts": [
|
||||
"hubot gho remove ",
|
||||
" ",
|
||||
" from team "
|
||||
]
|
||||
"inputParts": [
|
||||
"Remove members or repos?/members or repos",
|
||||
"Input a comma separated list to remove",
|
||||
"What is the name of the team to remove from?"
|
||||
]
|
||||
"gho delete team <team name>":
|
||||
"textParts": [
|
||||
"hubot gho delete team "
|
||||
]
|
||||
"inputParts": [
|
||||
"What is the name of the team to delete? (Max 1024 characters)"
|
||||
]
|
||||
}
|
||||
|
||||
module.exports = HubotQueryParts
|
|
@ -0,0 +1,298 @@
|
|||
# Contains helper methods and data structures for constructing and
|
||||
# combining cards to return to Teams.
|
||||
|
||||
HubotQueryParts = require './hubot-query-parts'
|
||||
|
||||
maybeConstructResponseCard = (response, query) ->
|
||||
# Check if response.text matches one of the reg exps in the LUT and
|
||||
# construct a card if so. Otherwise, return null
|
||||
for regex of HubotResponseCards
|
||||
regexObject = new RegExp(regex)
|
||||
if regexObject.test(query)
|
||||
card = initializeAdaptiveCard(query)
|
||||
card.content.body.push(addTextBlock(response.text))
|
||||
card.content.actions = getFollowUpButtons(query, regex)
|
||||
return card
|
||||
return null
|
||||
|
||||
# Constructs an input card or returns null if the
|
||||
# query doesn't need user input
|
||||
maybeConstructMenuInputCard = (query) ->
|
||||
queryParts = HubotQueryParts[query]
|
||||
|
||||
# Check if the query needs a user input card
|
||||
if queryParts.inputParts is undefined
|
||||
return null
|
||||
|
||||
shortQuery = constructShortQuery(query)
|
||||
card = initializeAdaptiveCard(shortQuery)
|
||||
|
||||
# Create the input fields of the sub card
|
||||
for i in [0 ... queryParts.inputParts.length]
|
||||
inputPart = queryParts.inputParts[i]
|
||||
index = inputPart.search('/')
|
||||
|
||||
# Create the prompt
|
||||
promptEnd = inputPart.length
|
||||
if index != -1
|
||||
promptEnd = index
|
||||
card.content.body.push(addTextBlock("#{inputPart.substring(0, promptEnd)}"))
|
||||
|
||||
# Create selector
|
||||
if index != -1
|
||||
card.content.body.push(addSelector(query, inputPart.substring(index + 1),
|
||||
query + " - input" + "#{i}"))
|
||||
# Create text input
|
||||
else
|
||||
card.content.body.push(addTextInput(query + " - input" + "#{i}", inputPart))
|
||||
|
||||
# Create the submit button
|
||||
data = {
|
||||
'queryPrefix': query
|
||||
}
|
||||
for i in [0 ... queryParts.textParts.length]
|
||||
textPart = queryParts.textParts[i]
|
||||
data[query + " - query" + "#{i}"] = textPart
|
||||
|
||||
card.content.actions = [
|
||||
{
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': data
|
||||
}
|
||||
]
|
||||
return card
|
||||
|
||||
# Initializes card structure
|
||||
initializeAdaptiveCard = (query) ->
|
||||
card = {
|
||||
'contentType': 'application/vnd.microsoft.card.adaptive'
|
||||
'content': {
|
||||
"type": "AdaptiveCard"
|
||||
"version": "1.0"
|
||||
"body": [
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "#{query}"
|
||||
'speak': "<s>#{query}</s>"
|
||||
'weight': 'bolder'
|
||||
'size': 'large'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return card
|
||||
|
||||
# Constructs an adaptive card text block to add to a card
|
||||
addTextBlock = (text) ->
|
||||
textBlock = {
|
||||
'type': 'TextBlock'
|
||||
'text': "#{text}"
|
||||
'speak': "<s>#{text}</s>"
|
||||
}
|
||||
return textBlock
|
||||
|
||||
# Constructs an adaptive card input selector to add to a card
|
||||
addSelector = (queryPrefix, choicesText, id) ->
|
||||
selector = {
|
||||
"type": "Input.ChoiceSet"
|
||||
"id": id
|
||||
"style": "compact"
|
||||
}
|
||||
choices = []
|
||||
for choice in choicesText.split(" or ")
|
||||
choices.push({
|
||||
'title': choice
|
||||
'value': choice
|
||||
})
|
||||
selector.choices = choices
|
||||
# Set the default value to the first choice
|
||||
selector.value = choices[0].value
|
||||
|
||||
return selector
|
||||
|
||||
# Constructs an adaptive card text input to add to a card
|
||||
addTextInput = (id, inputPart) ->
|
||||
textInput = {
|
||||
'type': 'Input.Text'
|
||||
'id': id
|
||||
'speak': "<s>#{inputPart}</s>"
|
||||
'wrap': true
|
||||
'style': 'text'
|
||||
'maxLength': 1024
|
||||
}
|
||||
return textInput
|
||||
|
||||
# Creates an array of JSON adaptive card actions to use for
|
||||
# a specific card
|
||||
getFollowUpButtons = (query, regex) ->
|
||||
actions = []
|
||||
for followUpQuery in HubotResponseCards[regex]
|
||||
shortQuery = constructShortQuery(followUpQuery)
|
||||
action = {
|
||||
'title': shortQuery
|
||||
}
|
||||
queryParts = HubotQueryParts[followUpQuery]
|
||||
|
||||
# Doesn't need user input, just run the command when the
|
||||
# follow up button is pressed
|
||||
if queryParts.inputParts is undefined
|
||||
action.type = 'Action.Submit'
|
||||
action.data = {
|
||||
'queryPrefix': followUpQuery
|
||||
}
|
||||
|
||||
# Add the text parts to the data field of the action
|
||||
for i in [0 ... queryParts.textParts.length]
|
||||
textPart = queryParts.textParts[i]
|
||||
action.data[followUpQuery + " - query" + "#{i}"] = textPart
|
||||
|
||||
# Construct a card to show with input fields for each user input
|
||||
# and a submit button containing the text parts
|
||||
else
|
||||
action.type = 'Action.ShowCard'
|
||||
# Add the title for the sub card
|
||||
action.card = {
|
||||
'type': 'AdaptiveCard'
|
||||
'body': [
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "#{shortQuery}"
|
||||
'speak': "<s>#{shortQuery}</s>"
|
||||
'weight': 'bolder'
|
||||
'size': 'large'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Create the input fields of the sub card
|
||||
for i in [0 ... queryParts.inputParts.length]
|
||||
inputPart = queryParts.inputParts[i]
|
||||
index = inputPart.search('/')
|
||||
|
||||
# Create the prompt
|
||||
promptEnd = inputPart.length
|
||||
if index != -1
|
||||
promptEnd = index
|
||||
action.card.body.push(addTextBlock(inputPart.substring(0, promptEnd)))
|
||||
|
||||
# Create selector
|
||||
if index != -1
|
||||
action.card.body.push(addSelector(followUpQuery, \
|
||||
inputPart.substring(index + 1), \
|
||||
followUpQuery + " - input" + "#{i}"))
|
||||
# Create text input
|
||||
else
|
||||
action.card.body.push(addTextInput(followUpQuery + " - input" + "#{i}", \
|
||||
inputPart))
|
||||
|
||||
# Create the submit button in the sub card
|
||||
data = {
|
||||
'queryPrefix': followUpQuery
|
||||
}
|
||||
for i in [0 ... queryParts.textParts.length]
|
||||
textPart = queryParts.textParts[i]
|
||||
data[followUpQuery + " - query" + "#{i}"] = textPart
|
||||
|
||||
action.card.actions = [
|
||||
{
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': data
|
||||
}
|
||||
]
|
||||
|
||||
# Add the action to actions
|
||||
actions.push(action)
|
||||
|
||||
return actions
|
||||
|
||||
# Appends the card body of card2 to card1, skipping
|
||||
# duplicate card body blocks, and returns card1. In the
|
||||
# case that both card bodies are undefined
|
||||
appendCardBody = (card1, card2) ->
|
||||
if card2.content.body is undefined
|
||||
return card1
|
||||
|
||||
if card1.content.body is undefined
|
||||
card1.content.body = card2.content.body
|
||||
return card1
|
||||
|
||||
for newBlock in card2.content.body
|
||||
hasBlock = false
|
||||
for storedBlock in card1.content.body
|
||||
if JSON.stringify(storedBlock) == JSON.stringify(newBlock)
|
||||
hasBlock = true
|
||||
break
|
||||
|
||||
if not hasBlock
|
||||
card1.content.body.push(newBlock)
|
||||
return card1
|
||||
|
||||
# Appends the card actions of card2 to those of card1, skipping
|
||||
# actions which card1 already contains
|
||||
appendCardActions = (card1, card2) ->
|
||||
if card2.content.actions is undefined
|
||||
return card1
|
||||
|
||||
if card1.content.actions is undefined
|
||||
card1.content.actions = card2.content.actions
|
||||
return card1
|
||||
|
||||
for newAction in card2.content.actions
|
||||
hasAction = false
|
||||
for storedAction in card1.content.actions
|
||||
if JSON.stringify(storedAction) == JSON.stringify(newAction)
|
||||
hasAction = true
|
||||
break
|
||||
|
||||
# if not in storedActions, add it
|
||||
if not hasAction
|
||||
card1.content.actions.push(newAction)
|
||||
return card1
|
||||
|
||||
# Helper method to create a short version of the command by including only the
|
||||
# start of the command to the first user input marked by ( or <
|
||||
constructShortQuery = (query) ->
|
||||
shortQueryEnd = query.search(new RegExp("[(<]"))
|
||||
if shortQueryEnd == -1
|
||||
shortQueryEnd = query.length
|
||||
shortQuery = query.substring(0, shortQueryEnd)
|
||||
return shortQuery.trim()
|
||||
|
||||
# HubotResponseCards maps from regex's of hubot queries to an array of follow up hubot
|
||||
# queries stored as strings
|
||||
HubotResponseCards = {
|
||||
"(.+) gho list (teams|repos|members)": [
|
||||
"gho list (teams|repos|members)",
|
||||
"gho list public repos"
|
||||
]
|
||||
"(.+) gho create team (.+){1,1024}": [
|
||||
"gho add (members|repos) <members|repos> to team <team name>",
|
||||
"gho list (teams|repos|members)",
|
||||
"gho delete team <team name>"
|
||||
]
|
||||
"(.+) gho create repo [^/]{1,1024}(|/(|private|public))$": [
|
||||
"gho add (members|repos) <members|repos> to team <team name>",
|
||||
"gho list (teams|repos|members)"
|
||||
]
|
||||
"(.+) gho add (repos|members) (.+)(,.)* to team (.+){1,1024}": [
|
||||
"gho remove (repos|members) <members|repos> from team <team name>"
|
||||
]
|
||||
"(.+) gho remove (repos|members) (.+)(,.)* from team (.+){1,1024}": [
|
||||
"gho add (members|repos) <members|repos> to team <team name>"
|
||||
]
|
||||
"(.+) gho delete team (.+){1,1024}": [
|
||||
"gho create team <team name>",
|
||||
"gho list (teams|repos|members)"
|
||||
]
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
maybeConstructResponseCard,
|
||||
maybeConstructMenuInputCard,
|
||||
appendCardBody,
|
||||
appendCardActions
|
||||
}
|
|
@ -17,25 +17,46 @@
|
|||
# 3. Properly handles chat vs. channel messages
|
||||
# 4. Optionally filters out messages from outside the tenant
|
||||
# 5. Properly handles image responses.
|
||||
# 6. Generates adaptive cards with follow up buttons for specific commands
|
||||
# 7. Optionally restricts authorization to Hubot to a defined list of users
|
||||
#
|
||||
# Author:
|
||||
# billbliss
|
||||
#
|
||||
|
||||
BotBuilderTeams = require 'botbuilder-teams'
|
||||
HubotResponseCards = require './hubot-response-cards'
|
||||
HubotQueryParts = require './hubot-query-parts'
|
||||
{ Robot, TextMessage, Message, User } = require 'hubot'
|
||||
{ BaseMiddleware, registerMiddleware } = require './adapter-middleware'
|
||||
LogPrefix = "hubot-msteams:"
|
||||
|
||||
|
||||
class MicrosoftTeamsMiddleware extends BaseMiddleware
|
||||
constructor: (@robot) ->
|
||||
constructor: (@robot, appId, appPassword) ->
|
||||
super(@robot)
|
||||
@appId = appId
|
||||
@appPassword = appPassword
|
||||
|
||||
@allowedTenants = []
|
||||
if process.env.HUBOT_OFFICE365_TENANT_FILTER?
|
||||
@allowedTenants = process.env.HUBOT_OFFICE365_TENANT_FILTER.split(",")
|
||||
@robot.logger.info("#{LogPrefix} Restricting tenants to #{JSON.stringify(@allowedTenants)}")
|
||||
@robot.logger.info("#{LogPrefix} Restricting tenants to \
|
||||
#{JSON.stringify(@allowedTenants)}")
|
||||
|
||||
toReceivable: (activity) ->
|
||||
# If the invoke is due to a command that needs user input, sends a user input card
|
||||
# otherwise, returns an event to handle, if needed, or null
|
||||
handleInvoke: (invokeEvent, connector) ->
|
||||
payload = @maybeConstructUserInputPrompt(invokeEvent)
|
||||
if payload != null
|
||||
@sendPayload(connector, payload)
|
||||
return null
|
||||
else
|
||||
invokeEvent.text = invokeEvent.value.hubotMessage
|
||||
delete invokeEvent.value
|
||||
return invokeEvent
|
||||
|
||||
toReceivable: (activity, chatMembers) ->
|
||||
@robot.logger.info "#{LogPrefix} toReceivable"
|
||||
|
||||
# Drop the activity if it came from an unauthorized tenant
|
||||
|
@ -47,16 +68,18 @@ class MicrosoftTeamsMiddleware extends BaseMiddleware
|
|||
user = getUser(activity)
|
||||
user = @robot.brain.userForId(user.id, user)
|
||||
|
||||
# We don't want to save the activity or room in the brain since its something that changes per chat.
|
||||
# We don't want to save the activity or room in the brain since its
|
||||
# something that changes per chat.
|
||||
user.activity = activity
|
||||
user.room = getRoomId(activity)
|
||||
|
||||
if activity.type == 'message'
|
||||
activity = fixActivityForHubot(activity, @robot)
|
||||
message = new TextMessage(user, activity.text, activity.address.id)
|
||||
return message
|
||||
# Return a generic message if the activity isn't a message or invoke
|
||||
if activity.type != 'message' && activity.type != 'invoke'
|
||||
return new Message(user)
|
||||
|
||||
return new Message(user)
|
||||
activity = fixActivityForHubot(activity, @robot, chatMembers)
|
||||
message = new TextMessage(user, activity.text, activity.address.id)
|
||||
return message
|
||||
|
||||
toSendable: (context, message) ->
|
||||
@robot.logger.info "#{LogPrefix} toSendable"
|
||||
|
@ -64,15 +87,23 @@ class MicrosoftTeamsMiddleware extends BaseMiddleware
|
|||
|
||||
response = message
|
||||
if typeof message is 'string'
|
||||
# Trim leading or ending whitespace
|
||||
response =
|
||||
type: 'message'
|
||||
text: message
|
||||
text: message.trim()
|
||||
address: activity?.address
|
||||
|
||||
imageAttachment = convertToImageAttachment(message)
|
||||
if imageAttachment?
|
||||
|
||||
# If the query sent by the user should trigger a card,
|
||||
# construct the card to attach to the response
|
||||
card = HubotResponseCards.maybeConstructResponseCard(response, activity.text)
|
||||
if card != null
|
||||
delete response.text
|
||||
response.attachments = [imageAttachment]
|
||||
response.attachments = [card]
|
||||
else
|
||||
imageAttachment = convertToImageAttachment(message)
|
||||
if imageAttachment?
|
||||
delete response.text
|
||||
response.attachments = [imageAttachment]
|
||||
|
||||
response = fixMessageForTeams(response, @robot)
|
||||
|
||||
|
@ -82,6 +113,141 @@ class MicrosoftTeamsMiddleware extends BaseMiddleware
|
|||
|
||||
return [typingMessage, response]
|
||||
|
||||
# Converts the activity to a hubot message and passes it to
|
||||
# hubot for reception on success
|
||||
maybeReceive: (activity, connector, authEnabled) ->
|
||||
# Fetch the roster of members to do authorization, if enabled, based on UPN
|
||||
teamsConnector = new BotBuilderTeams.TeamsChatConnector {
|
||||
appId: @appId
|
||||
appPassword: @appPassword
|
||||
}
|
||||
teamsConnector.fetchMembers activity?.address?.serviceUrl, \
|
||||
activity?.address?.conversation?.id, (err, chatMembers) =>
|
||||
if err
|
||||
throw err
|
||||
# Return with unauthorized error as true if auth is enabled and the user who sent
|
||||
# the message is not authorized
|
||||
if authEnabled
|
||||
authorizedUsers = @robot.brain.get("authorizedUsers")
|
||||
user = getUser(activity)
|
||||
senderUPN = getSenderUPN(user, chatMembers).toLowerCase()
|
||||
if senderUPN is undefined or authorizedUsers[senderUPN] is undefined
|
||||
@robot.logger.info "#{LogPrefix} Unauthorized user; returning error"
|
||||
text = "You are not authorized to send commands to hubot.
|
||||
To gain access, talk to your admins:"
|
||||
errorResponse = @constructErrorResponse(activity, text, true)
|
||||
@send(connector, errorResponse)
|
||||
return
|
||||
|
||||
# Add the sender's UPN to the activity
|
||||
activity.address.user.userPrincipalName = senderUPN
|
||||
|
||||
# Convert the message to a hubot understandable form and
|
||||
# send to the robot on success
|
||||
event = @toReceivable activity, chatMembers
|
||||
if event?
|
||||
@robot.receive event
|
||||
|
||||
# Combines payloads then sends the combined payload to MS Teams
|
||||
send: (connector, payload) ->
|
||||
# The message is from Teams, so combine hubot responses
|
||||
# received within the next 100 ms then send the combined
|
||||
# response
|
||||
if @robot.brain.get("justReceivedResponse") is null
|
||||
@robot.brain.set("teamsResponse", payload)
|
||||
@robot.brain.set("justReceivedResponse", true)
|
||||
setTimeout(@sendPayload.bind(this), 100, connector, @robot.brain.get("teamsResponse"))
|
||||
else
|
||||
@combineResponses(@robot.brain.get("teamsResponse"), payload)
|
||||
|
||||
sendPayload: (connector, payload) ->
|
||||
if !Array.isArray(payload)
|
||||
payload = [payload]
|
||||
connector.send payload, (err, _) =>
|
||||
if err
|
||||
throw err
|
||||
@robot.brain.remove("teamsResponse")
|
||||
@robot.brain.remove("justReceivedResponse")
|
||||
|
||||
# Combines the text and attachments of multiple hubot messages sent in succession.
|
||||
# Most of the first received response is kept, and the text and attachments of
|
||||
# subsequent responses received within 100ms of the first are combined into the
|
||||
# first response. Assumes inputs follow the format of the payload returned by
|
||||
# toSendable
|
||||
combineResponses: (storedPayload, newPayload) ->
|
||||
storedMessage = storedPayload[1]
|
||||
newMessage = newPayload[1]
|
||||
|
||||
# Combine the payload text, if needed, separated by a break
|
||||
if newMessage.text != undefined
|
||||
if storedMessage.text != undefined
|
||||
storedMessage.text = "#{storedMessage.text}\r\n#{newMessage.text}"
|
||||
else
|
||||
storedMessage.text = newMessage.text
|
||||
|
||||
# Combine attachments, if needed
|
||||
if newMessage.attachments != undefined
|
||||
# If the stored message doesn't have attachments and the new one does,
|
||||
# just store the new attachments
|
||||
if storedMessage.attachments == undefined
|
||||
storedMessage.attachments = newMessage.attachments
|
||||
|
||||
# Otherwise, combine them
|
||||
else
|
||||
storedCard = searchForAdaptiveCard(storedMessage.attachments)
|
||||
# If the stored message doesn't have an adaptive card, just append the new
|
||||
# attachments
|
||||
if storedCard == null
|
||||
for attachment in newMessage.attachments
|
||||
storedMessage.attachments.push(attachment)
|
||||
else
|
||||
for attachment in newMessage.attachments
|
||||
# If it's not an adaptive card, just append it, otherwise
|
||||
# combine the cards
|
||||
if attachment.contentType != "application/vnd.microsoft.card.adaptive"
|
||||
storedMessage.attachments.push(attachment)
|
||||
else
|
||||
storedCard = HubotResponseCards.appendCardBody(storedCard, \
|
||||
attachment)
|
||||
storedCard = HubotResponseCards.appendCardActions(storedCard, \
|
||||
attachment)
|
||||
|
||||
# Constructs a text message response to indicate an error to the user in the
|
||||
# message channel they are using
|
||||
constructErrorResponse: (activity, text, appendAdmins) ->
|
||||
if appendAdmins
|
||||
authorizedUsers = @robot.brain.get("authorizedUsers")
|
||||
for userKey, isAdmin of authorizedUsers
|
||||
if isAdmin
|
||||
text = "#{text}\r\n- #{userKey}"
|
||||
|
||||
payload =
|
||||
type: 'message'
|
||||
text: "#{text}"
|
||||
address: activity?.address
|
||||
|
||||
return packagePayload(activity, payload)
|
||||
|
||||
# Constructs a response containing a card for user input if needed or null
|
||||
# if user input is not needed
|
||||
maybeConstructUserInputPrompt: (event) ->
|
||||
query = event.value.hubotMessage
|
||||
# Remove hubot from the beginning of the command if it's there
|
||||
query = query.replace("hubot ", "")
|
||||
|
||||
card = HubotResponseCards.maybeConstructMenuInputCard(query)
|
||||
if card is null
|
||||
return null
|
||||
|
||||
message =
|
||||
type: 'message'
|
||||
address: event?.address
|
||||
attachments: [
|
||||
card
|
||||
]
|
||||
|
||||
return packagePayload(event, message)
|
||||
|
||||
#############################################################################
|
||||
# Helper methods for generating richer messages
|
||||
#############################################################################
|
||||
|
@ -109,12 +275,26 @@ class MicrosoftTeamsMiddleware extends BaseMiddleware
|
|||
id: activity?.address?.user?.id,
|
||||
name: activity?.address?.user?.name,
|
||||
tenant: getTenantId(activity)
|
||||
aadObjectId: getUserAadObjectId(activity)
|
||||
userPrincipalName: activity?.address?.user?.userPrincipalName
|
||||
return user
|
||||
|
||||
# Fetches the user's name from the activity
|
||||
getUserName = (activity) ->
|
||||
return activity?.address?.user?.name
|
||||
|
||||
# Fetches the user's AAD Object Id from the activity
|
||||
getUserAadObjectId = (activity) ->
|
||||
return activity?.address?.user?.aadObjectId
|
||||
|
||||
# Fetches the room id from the activity
|
||||
getRoomId = (activity) ->
|
||||
return activity?.address?.conversation?.id
|
||||
|
||||
# Fetches the conversation type from the activity
|
||||
getConversationType = (activity) ->
|
||||
return activity?.address?.conversation?.conversationType
|
||||
|
||||
# Fetches the tenant id from the activity
|
||||
getTenantId = (activity) ->
|
||||
return activity?.sourceEvent?.tenant?.id
|
||||
|
@ -124,35 +304,91 @@ class MicrosoftTeamsMiddleware extends BaseMiddleware
|
|||
entities = activity?.entities || []
|
||||
if not Array.isArray(entities)
|
||||
entities = [entities]
|
||||
return entities.filter((entity) -> entity.type == "mention" && (not userId? || userId == entity.mentioned?.id))
|
||||
return entities.filter((entity) -> entity.type == "mention" && \
|
||||
(not userId? || userId == entity.mentioned?.id))
|
||||
|
||||
# Returns the provided user's userPrincipalName (UPN) or null if one cannot be found
|
||||
getSenderUPN = (user, chatMembers) ->
|
||||
userAadObjectId = user.aadObjectId
|
||||
for member in chatMembers
|
||||
if userAadObjectId == member.objectId
|
||||
return member.userPrincipalName
|
||||
return null
|
||||
|
||||
# Fixes the activity to have the proper information for Hubot
|
||||
# 1. Replaces all occurances of the channel's bot at mention name with the configured name in hubot.
|
||||
# 1. Constructs the text command to send to hubot if the event is from a
|
||||
# submit on an adaptive card (containing the value property).
|
||||
# 2. Replaces all occurrences of the channel's bot at mention name with the configured
|
||||
# name in hubot.
|
||||
# The hubot's configured name might not be the same name that is sent from the chat service in
|
||||
# the activity's text.
|
||||
# 2. Prepends hubot's name to the message if this is a direct message.
|
||||
fixActivityForHubot = (activity, robot) ->
|
||||
# 3. Replaces all occurrences of @ mentions to users with their aad object id if the user is
|
||||
# on the roster of chanenl members from Teams. If a mentioned user is not in the chat roster,
|
||||
# the mention is replaced with their name.
|
||||
# 4. Trims all whitespace and newlines from the beginning and end of the text.
|
||||
# 5. Prepends hubot's name to the message for personal messages if it's not already
|
||||
# there
|
||||
fixActivityForHubot = (activity, robot, chatMembers) ->
|
||||
# If activity.value exists, the command is from a follow up button press on
|
||||
# a card and the correct query to send to hubot should be constructed
|
||||
if activity?.value != undefined
|
||||
data = activity.value
|
||||
|
||||
# Used to uniquely identify command parts since adaptive cards
|
||||
# don't differentiate between different sub-cards' data fields
|
||||
queryPrefix = data.queryPrefix
|
||||
|
||||
# Get the first command part. A command always begins with a text part
|
||||
# since if activity.value is populated, the command is from a card we
|
||||
# created, and we always include at least hubot at the beginning of
|
||||
# these commands
|
||||
text = data[queryPrefix + " - query0"]
|
||||
text = text.replace("hubot", robot.name)
|
||||
|
||||
# If there are inputs, add those and the next query part
|
||||
# if there is another query part
|
||||
i = 0
|
||||
input = data[queryPrefix + " - input#{i}"]
|
||||
while input != undefined
|
||||
text = text + input
|
||||
nextTextPart = data[queryPrefix + " - query" + (i + 1)]
|
||||
if nextTextPart != undefined
|
||||
text = text + nextTextPart
|
||||
i++
|
||||
input = data[queryPrefix + " - input#{i}"]
|
||||
|
||||
# Set the constructed query as the text of the activity
|
||||
activity.text = text
|
||||
|
||||
if not activity?.text? || typeof activity.text isnt 'string'
|
||||
return activity
|
||||
|
||||
myChatId = activity?.address?.bot?.id
|
||||
if not myChatId?
|
||||
return activity
|
||||
|
||||
# replace all @ mentions with the robot's name
|
||||
# Replace all @ mentions to the bot with the bot's name, and replace
|
||||
# all @ mentions of users with a known aad object id with their aad
|
||||
# object id.
|
||||
mentions = getMentions(activity)
|
||||
for mention in mentions
|
||||
mentionTextRegExp = new RegExp(escapeRegExp(mention.text), "gi")
|
||||
replacement = mention.mentioned.name
|
||||
if mention.mentioned.id == myChatId
|
||||
replacement = robot.name
|
||||
|
||||
if chatMembers != undefined
|
||||
for member in chatMembers
|
||||
if mention.mentioned.id == member.id
|
||||
replacement = member.userPrincipalName
|
||||
activity.text = activity.text.replace(mentionTextRegExp, replacement)
|
||||
|
||||
# Remove leading/trailing whitespace and newlines
|
||||
activity.text = activity.text.trim()
|
||||
|
||||
# prepends the robot's name for direct messages
|
||||
roomId = getRoomId(activity)
|
||||
if roomId? and not roomId.startsWith("19:") and not activity.text.startsWith(robot.name)
|
||||
# prepends the robot's name for direct messages if it's not already there
|
||||
if getConversationType(activity) == 'personal' && activity.text.search(robot.name) != 0
|
||||
activity.text = "#{robot.name} #{activity.text}"
|
||||
|
||||
|
||||
return activity
|
||||
|
||||
slackMentionRegExp = /<@([^\|>]*)\|?([^>]*)>/g
|
||||
|
@ -161,10 +397,11 @@ class MicrosoftTeamsMiddleware extends BaseMiddleware
|
|||
# 1. Replaces all slack @ mentions with Teams @ mentions
|
||||
# Slack mentions take the form of <@[username or id]|[mention text]>
|
||||
# We have to convert this into a mention object which needs the id.
|
||||
# 2. Escapes all < to render 'hubot help' properly
|
||||
# 3. Replaces all newlines with break tags to render line breaks properly
|
||||
fixMessageForTeams = (response, robot) ->
|
||||
if not response?.text?
|
||||
return response
|
||||
|
||||
mentions = []
|
||||
while match = slackMentionRegExp.exec(response.text)
|
||||
foundUser = null
|
||||
|
@ -189,11 +426,42 @@ class MicrosoftTeamsMiddleware extends BaseMiddleware
|
|||
response.text = response.text.replace(mentionTextRegExp, mention.text)
|
||||
delete mention.full
|
||||
response.entities = mentions
|
||||
|
||||
# Escape < in hubot help commands, determined by the response text
|
||||
# starting with 'hubot'
|
||||
if response.text.search("hubot") == 0
|
||||
response.text = escapeLessThan(response.text)
|
||||
|
||||
# Replace \n with html <br/> for rendering breaks in Teams
|
||||
response.text = escapeNewLines(response.text)
|
||||
|
||||
return response
|
||||
|
||||
escapeRegExp = (str) ->
|
||||
return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&")
|
||||
|
||||
escapeLessThan = (str) ->
|
||||
str = str.replace(/</g, "<")
|
||||
return str
|
||||
|
||||
escapeNewLines = (str) ->
|
||||
return str.replace(/\n/g, "<br/>")
|
||||
|
||||
# Helper method for retrieving the first adaptive card from a list of
|
||||
# attachments or null if there are none
|
||||
searchForAdaptiveCard = (attachments) ->
|
||||
card = null
|
||||
for attachment in attachments
|
||||
if attachment.contentType == "application/vnd.microsoft.card.adaptive"
|
||||
card = attachment
|
||||
return card
|
||||
|
||||
packagePayload = (activity, message) ->
|
||||
typing =
|
||||
type: 'typing'
|
||||
address: activity?.address
|
||||
return [typing, message]
|
||||
|
||||
|
||||
registerMiddleware 'msteams', MicrosoftTeamsMiddleware
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ expect = chai.expect
|
|||
{ TextMessage, Message, User } = require 'hubot'
|
||||
MockRobot = require './mock-robot'
|
||||
{ BaseMiddleware, TextMiddleware, middlewareFor } = require '../src/adapter-middleware'
|
||||
BotBuilderTeams = require('./mock-botbuilder-teams')
|
||||
|
||||
describe 'middlewareFor', ->
|
||||
it 'should return Middleware for null', ->
|
||||
|
@ -76,9 +77,48 @@ describe 'BaseMiddleware', ->
|
|||
).to.throw()
|
||||
|
||||
describe 'TextMiddleware', ->
|
||||
describe 'handleInvoke', ->
|
||||
robot = null
|
||||
event = null
|
||||
connector = null
|
||||
appId = 'a-app-id'
|
||||
appPassword = 'a-app-password'
|
||||
beforeEach ->
|
||||
robot = new MockRobot
|
||||
event =
|
||||
type: 'invoke'
|
||||
text: 'Bot do something and tell User about it'
|
||||
agent: 'tests'
|
||||
source: '*'
|
||||
address:
|
||||
conversation:
|
||||
id: "conversation-id"
|
||||
bot:
|
||||
id: "bot-id"
|
||||
user:
|
||||
id: "user-id"
|
||||
name: "user-name"
|
||||
connector =
|
||||
send: () -> {}
|
||||
|
||||
it 'should return null', ->
|
||||
# Setup
|
||||
middleware = new TextMiddleware(robot, appId, appPassword)
|
||||
|
||||
# Action
|
||||
result = null
|
||||
expect(() ->
|
||||
result = middleware.handleInvoke(event, connector)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(result).to.be.null
|
||||
|
||||
describe 'toReceivable', ->
|
||||
robot = null
|
||||
event = null
|
||||
appId = 'a-app-id'
|
||||
appPassword = 'a-app-password'
|
||||
beforeEach ->
|
||||
robot = new MockRobot
|
||||
event =
|
||||
|
@ -98,7 +138,7 @@ describe 'TextMiddleware', ->
|
|||
it 'return generic message when appropriate type is not found', ->
|
||||
# Setup
|
||||
event.type = 'typing'
|
||||
middleware = new TextMiddleware(robot)
|
||||
middleware = new TextMiddleware(robot, appId, appPassword)
|
||||
|
||||
# Action
|
||||
receivable = null
|
||||
|
@ -111,7 +151,7 @@ describe 'TextMiddleware', ->
|
|||
|
||||
it 'return message when type is message', ->
|
||||
# Setup
|
||||
middleware = new TextMiddleware(robot)
|
||||
middleware = new TextMiddleware(robot, appId, appPassword)
|
||||
|
||||
# Action
|
||||
receivable = null
|
||||
|
@ -126,6 +166,8 @@ describe 'TextMiddleware', ->
|
|||
robot = null
|
||||
message = null
|
||||
context = null
|
||||
appId = 'a-app-id'
|
||||
appPassword = 'a-app-password'
|
||||
beforeEach ->
|
||||
robot = new MockRobot
|
||||
context =
|
||||
|
@ -149,7 +191,7 @@ describe 'TextMiddleware', ->
|
|||
|
||||
it 'should create message object for string messages', ->
|
||||
# Setup
|
||||
middleware = new TextMiddleware(robot)
|
||||
middleware = new TextMiddleware(robot, appId, appPassword)
|
||||
|
||||
# Action
|
||||
sendable = null
|
||||
|
@ -169,7 +211,7 @@ describe 'TextMiddleware', ->
|
|||
# Setup
|
||||
message =
|
||||
type: "some message type"
|
||||
middleware = new TextMiddleware(robot)
|
||||
middleware = new TextMiddleware(robot, appId, appPassword)
|
||||
|
||||
# Action
|
||||
sendable = null
|
||||
|
@ -180,3 +222,207 @@ describe 'TextMiddleware', ->
|
|||
# Verify
|
||||
expected = message
|
||||
expect(sendable).to.deep.equal(expected)
|
||||
|
||||
describe 'maybeReceive', ->
|
||||
robot = null
|
||||
middleware = null
|
||||
authEnabled = true
|
||||
connector = null
|
||||
event = null
|
||||
|
||||
beforeEach ->
|
||||
robot = new MockRobot
|
||||
middleware = new TextMiddleware(robot, 'a-app-id', 'a-app-password')
|
||||
connector =
|
||||
send: (payload, cb) ->
|
||||
robot.brain.set("payload", payload)
|
||||
authEnabled = true
|
||||
event =
|
||||
type: 'message'
|
||||
text: '<at>Bot</at> do something <at>Bot</at> and tell <at>User</at> about it'
|
||||
agent: 'tests'
|
||||
source: 'msteams'
|
||||
entities: [
|
||||
type: "mention"
|
||||
text: "<at>Bot</at>"
|
||||
mentioned:
|
||||
id: "bot-id"
|
||||
name: "bot-name"
|
||||
,
|
||||
type: "mention"
|
||||
text: "<at>User</at>"
|
||||
mentioned:
|
||||
id: "user-id"
|
||||
name: "user-name"
|
||||
]
|
||||
sourceEvent:
|
||||
tenant:
|
||||
id: "tenant-id"
|
||||
address:
|
||||
conversation:
|
||||
id: "19:conversation-id"
|
||||
bot:
|
||||
id: "bot-id"
|
||||
user:
|
||||
id: "user-id"
|
||||
name: "user-name"
|
||||
aadObjectId: "eight888-four-4444-fore-twelve121212"
|
||||
userPrincipalName: "user-UPN"
|
||||
serviceUrl: 'url-serviceUrl/a-url'
|
||||
|
||||
it 'should return authorization not supported error when auth is enabled', ->
|
||||
# Setup
|
||||
|
||||
# Action
|
||||
expect(() ->
|
||||
middleware.maybeReceive(event, connector, authEnabled)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
resultEvent = robot.brain.get("event")
|
||||
expect(resultEvent).to.be.null
|
||||
resultPayload = robot.brain.get("payload")
|
||||
expect(resultPayload).to.be.a('Array')
|
||||
expect(resultPayload.length).to.eql 1
|
||||
expect(resultPayload[0].text).to.eql "Authorization isn't supported for this channel"
|
||||
|
||||
it 'should work when auth is not enabled', ->
|
||||
# Setup
|
||||
authEnabled = false
|
||||
|
||||
# Action
|
||||
expect(() ->
|
||||
middleware.maybeReceive(event, connector, authEnabled)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
resultEvent = robot.brain.get("event")
|
||||
expect(resultEvent).to.not.be.null
|
||||
expect(resultEvent).to.be.a('Object')
|
||||
resultPayload = robot.brain.get("payload")
|
||||
expect(resultPayload).to.be.null
|
||||
|
||||
describe 'constructErrorResponse', ->
|
||||
it 'return a proper payload with the text of the error', ->
|
||||
# Setup
|
||||
robot = new MockRobot
|
||||
middleware = new TextMiddleware(robot, 'a-app-id', 'a-app-password')
|
||||
event =
|
||||
type: 'message'
|
||||
text: 'Bot do something and tell User about it'
|
||||
agent: 'tests'
|
||||
source: '*'
|
||||
address:
|
||||
conversation:
|
||||
id: "conversation-id"
|
||||
bot:
|
||||
id: "bot-id"
|
||||
user:
|
||||
id: "user-id"
|
||||
name: "user-name"
|
||||
|
||||
# Action
|
||||
result = null
|
||||
expect(() ->
|
||||
result = middleware.constructErrorResponse(event, "an error message")
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(result).to.eql {
|
||||
type: 'message'
|
||||
text: 'an error message'
|
||||
address:
|
||||
conversation:
|
||||
id: "conversation-id"
|
||||
bot:
|
||||
id: "bot-id"
|
||||
user:
|
||||
id: "user-id"
|
||||
name: "user-name"
|
||||
}
|
||||
|
||||
describe 'send', ->
|
||||
robot = null
|
||||
middleware = null
|
||||
connector = null
|
||||
payload = null
|
||||
cb = () -> {}
|
||||
|
||||
beforeEach ->
|
||||
robot = new MockRobot
|
||||
middleware = new TextMiddleware(robot, 'a-app-id', 'a-app-password')
|
||||
connector = new BotBuilderTeams.TeamsChatConnector({
|
||||
appId: 'a-app-id'
|
||||
appPassword: 'a-app-password'
|
||||
})
|
||||
connector.send = (payload, cb) ->
|
||||
robot.brain.set("payload", payload)
|
||||
|
||||
payload = {
|
||||
type: 'message'
|
||||
text: ""
|
||||
address:
|
||||
conversation:
|
||||
isGroup: 'true'
|
||||
conversationType: 'channel'
|
||||
id: "19:conversation-id"
|
||||
bot:
|
||||
id: 'a-app-id'
|
||||
user:
|
||||
id: "user-id"
|
||||
name: "user-name"
|
||||
}
|
||||
|
||||
it 'should package non-array payload in array before sending', ->
|
||||
# Setup
|
||||
expected = [{
|
||||
type: 'message'
|
||||
text: ""
|
||||
address:
|
||||
conversation:
|
||||
isGroup: 'true'
|
||||
conversationType: 'channel'
|
||||
id: "19:conversation-id"
|
||||
bot:
|
||||
id: 'a-app-id'
|
||||
user:
|
||||
id: "user-id"
|
||||
name: "user-name"
|
||||
}]
|
||||
|
||||
# Action
|
||||
expect(() ->
|
||||
middleware.send(connector, payload)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
result = robot.brain.get("payload")
|
||||
expect(result).to.deep.eql(expected)
|
||||
|
||||
|
||||
it 'should pass payload array through unchanged', ->
|
||||
# Setup
|
||||
payload = [payload]
|
||||
expected = [{
|
||||
type: 'message'
|
||||
text: ""
|
||||
address:
|
||||
conversation:
|
||||
isGroup: 'true'
|
||||
conversationType: 'channel'
|
||||
id: "19:conversation-id"
|
||||
bot:
|
||||
id: 'a-app-id'
|
||||
user:
|
||||
id: "user-id"
|
||||
name: "user-name"
|
||||
}]
|
||||
|
||||
# Action
|
||||
expect(() ->
|
||||
middleware.send(connector, payload)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
result = robot.brain.get("payload")
|
||||
expect(result).to.deep.eql(expected)
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
chai = require 'chai'
|
||||
expect = chai.expect
|
||||
{ TextMessage, Message, Robot, User } = require 'hubot'
|
||||
BotFrameworkAdapter = require '../src/adapter'
|
||||
MockRobot = require './mock-robot'
|
||||
|
||||
|
||||
describe 'Main Adapter', ->
|
||||
describe 'Test Authorization Setup', ->
|
||||
beforeEach ->
|
||||
process.env.HUBOT_TEAMS_INITIAL_ADMINS = 'an-1_20@em.ail,authorized_user@email.la'
|
||||
process.env.HUBOT_TEAMS_ENABLE_AUTH = 'true'
|
||||
|
||||
it 'should not set initial admins when auth enable is not set', ->
|
||||
# Setup
|
||||
delete process.env.HUBOT_TEAMS_ENABLE_AUTH
|
||||
robot = new MockRobot
|
||||
|
||||
# Action
|
||||
expect(() ->
|
||||
adapter = BotFrameworkAdapter.use(robot)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(robot.brain.get("authorizedUsers")).to.be.null
|
||||
|
||||
it 'should not set initial admins when auth is not enabled', ->
|
||||
# Setup
|
||||
process.env.HUBOT_TEAMS_ENABLE_AUTH = 'false'
|
||||
robot = new MockRobot
|
||||
|
||||
# Action
|
||||
expect(() ->
|
||||
adapter = BotFrameworkAdapter.use(robot)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(robot.brain.get("authorizedUsers")).to.be.null
|
||||
|
||||
it 'should throw error when auth is enabled and initial admins', ->
|
||||
# Setup
|
||||
delete process.env.HUBOT_TEAMS_INITIAL_ADMINS
|
||||
robot = new MockRobot
|
||||
|
||||
# Action and Assert
|
||||
expect(() ->
|
||||
adapter = BotFrameworkAdapter.use(robot)
|
||||
).to.throw()
|
||||
|
||||
it 'should set initial admins when auth is enabled', ->
|
||||
# Setup
|
||||
robot = new MockRobot
|
||||
|
||||
# Action
|
||||
expect(() ->
|
||||
adapter = BotFrameworkAdapter.use(robot)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(robot.brain.get("authorizedUsers")).to.eql {
|
||||
'an-1_20@em.ail': true
|
||||
'authorized_user@email.la': true
|
||||
}
|
|
@ -0,0 +1,708 @@
|
|||
# Description:
|
||||
# Tests for helper methods used to construct Adaptive Cards for specific hubot
|
||||
# commands when used with the Botframework adapter
|
||||
|
||||
chai = require 'chai'
|
||||
expect = chai.expect
|
||||
|
||||
HubotResponseCards = require '../src/hubot-response-cards'
|
||||
|
||||
describe 'HubotResponseCards', ->
|
||||
describe 'maybeConstructResponseCard', ->
|
||||
query = null
|
||||
response = null
|
||||
beforeEach ->
|
||||
query = 'hubot gho create team team-name'
|
||||
response = {
|
||||
type: 'message',
|
||||
text: 'The team: `team-name` was successfully created',
|
||||
address: {
|
||||
id: 'id',
|
||||
channelId: 'msteams',
|
||||
user: {
|
||||
id: 'user-id',
|
||||
name: 'user-name',
|
||||
aadObjectId: 'user-aad-id'
|
||||
userPrincipalName: 'user-UPN'
|
||||
},
|
||||
conversation: {
|
||||
conversationType: 'conversation-type',
|
||||
id: 'conversation-id'
|
||||
},
|
||||
bot: {
|
||||
id: 'botframework-bot-id',
|
||||
name: 'botframework-bot-name'
|
||||
},
|
||||
serviceUrl: 'a-service-url'
|
||||
}
|
||||
}
|
||||
|
||||
it 'should not construct response card for the query', ->
|
||||
# Setup
|
||||
query = 'hubot ping'
|
||||
|
||||
# Action
|
||||
card = null
|
||||
expect(() ->
|
||||
card = HubotResponseCards.maybeConstructResponseCard(response, query)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(card).to.be.null
|
||||
|
||||
it 'should construct response card for the query', ->
|
||||
# Setup
|
||||
query = 'hubot gho create team team-name'
|
||||
followUp1 = 'gho add (members|repos) <members|repos> to team <team name>'
|
||||
followUp2 = 'gho list (teams|repos|members)'
|
||||
followUp3 = 'gho delete team <team name>'
|
||||
expected = {
|
||||
'contentType': 'application/vnd.microsoft.card.adaptive'
|
||||
'content': {
|
||||
"type": "AdaptiveCard"
|
||||
"version": "1.0"
|
||||
"body": [
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "#{query}"
|
||||
'speak': "<s>#{query}</s>"
|
||||
'weight': 'bolder'
|
||||
'size': 'large'
|
||||
},
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "#{response.text}"
|
||||
'speak': "<s>#{response.text}</s>"
|
||||
}
|
||||
],
|
||||
'actions': [
|
||||
{
|
||||
"title": "gho add"
|
||||
"type": "Action.ShowCard"
|
||||
"card": {
|
||||
"type": "AdaptiveCard"
|
||||
"body": [
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "gho add"
|
||||
'speak': "<s>gho add</s>"
|
||||
'weight': 'bolder'
|
||||
'size': 'large'
|
||||
},
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': 'Add members or repos?'
|
||||
'speak': "<s>Add members or repos?</s>"
|
||||
},
|
||||
{
|
||||
"type": "Input.ChoiceSet"
|
||||
"id": "#{followUp1} - input0"
|
||||
"style": "compact"
|
||||
"value": "members"
|
||||
"choices": [
|
||||
{
|
||||
"title": "members"
|
||||
"value": "members"
|
||||
},
|
||||
{
|
||||
"title": "repos"
|
||||
"value": "repos"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': 'Input a comma separated list to add'
|
||||
'speak': "<s>Input a comma separated list to add</s>"
|
||||
},
|
||||
{
|
||||
'type': 'Input.Text'
|
||||
'id': "#{followUp1} - input1"
|
||||
'speak': '<s>Input a comma separated list to add</s>'
|
||||
'wrap': true
|
||||
'style': 'text'
|
||||
'maxLength': 1024
|
||||
},
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': 'What is the name of the team to add to?'
|
||||
'speak': "<s>What is the name of the team to add to?</s>"
|
||||
},
|
||||
{
|
||||
'type': 'Input.Text'
|
||||
'id': "#{followUp1} - input2"
|
||||
'speak': '<s>What is the name of the team to add to?</s>'
|
||||
'wrap': true
|
||||
'style': 'text'
|
||||
'maxLength': 1024
|
||||
}
|
||||
],
|
||||
'actions': [
|
||||
{
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': {
|
||||
'queryPrefix': "#{followUp1}"
|
||||
"#{followUp1} - query0": 'hubot gho add '
|
||||
"#{followUp1} - query1": ' '
|
||||
"#{followUp1} - query2": ' to team '
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "gho list"
|
||||
"type": "Action.ShowCard"
|
||||
"card": {
|
||||
"type": "AdaptiveCard"
|
||||
"body": [
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "gho list"
|
||||
'speak': "<s>gho list</s>"
|
||||
'weight': 'bolder'
|
||||
'size': 'large'
|
||||
},
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': 'List what?'
|
||||
'speak': "<s>List what?</s>"
|
||||
},
|
||||
{
|
||||
"type": "Input.ChoiceSet"
|
||||
"id": "#{followUp2} - input0"
|
||||
"style": "compact"
|
||||
"value": "teams"
|
||||
"choices": [
|
||||
{
|
||||
"title": "teams"
|
||||
"value": "teams"
|
||||
},
|
||||
{
|
||||
"title": "repos"
|
||||
"value": "repos"
|
||||
},
|
||||
{
|
||||
"title": "members"
|
||||
"value": "members"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
'actions': [
|
||||
{
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': {
|
||||
'queryPrefix': "#{followUp2}"
|
||||
"#{followUp2} - query0": 'hubot gho list '
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "gho delete team"
|
||||
"type": "Action.ShowCard"
|
||||
"card": {
|
||||
"type": "AdaptiveCard"
|
||||
"body": [
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "gho delete team"
|
||||
'speak': "<s>gho delete team</s>"
|
||||
'weight': 'bolder'
|
||||
'size': 'large'
|
||||
},
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': 'What is the name of the team to delete? (Max 1024 characters)'
|
||||
'speak': "<s>What is the name of the team to delete? (Max 1024 characters)</s>"
|
||||
},
|
||||
{
|
||||
'type': 'Input.Text'
|
||||
'id': "#{followUp3} - input0"
|
||||
'speak': "<s>What is the name of the team to delete? (Max 1024 characters)</s>"
|
||||
'wrap': true
|
||||
'style': 'text'
|
||||
'maxLength': 1024
|
||||
}
|
||||
],
|
||||
'actions': [
|
||||
{
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': {
|
||||
'queryPrefix': "#{followUp3}"
|
||||
"#{followUp3} - query0": 'hubot gho delete team '
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# Action
|
||||
card = null
|
||||
expect(() ->
|
||||
card = HubotResponseCards.maybeConstructResponseCard(response, query)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(card).to.eql(expected)
|
||||
|
||||
describe 'maybeConstructMenuInputCard', ->
|
||||
it 'should not construct menu input card for the query', ->
|
||||
# Setup
|
||||
query = 'ping'
|
||||
|
||||
# Action
|
||||
result = null
|
||||
expect(() ->
|
||||
result = HubotResponseCards.maybeConstructMenuInputCard(query)
|
||||
)
|
||||
|
||||
# Assert
|
||||
expect(result).to.be.null
|
||||
|
||||
it 'should construct menu input card for the query', ->
|
||||
# Setup
|
||||
query = 'gho list (teams|repos|members)'
|
||||
expected = {
|
||||
'contentType': 'application/vnd.microsoft.card.adaptive'
|
||||
'content': {
|
||||
"type": "AdaptiveCard"
|
||||
"version": "1.0"
|
||||
"body": [
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "gho list"
|
||||
'speak': "<s>gho list</s>"
|
||||
'weight': 'bolder'
|
||||
'size': 'large'
|
||||
},
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': 'List what?'
|
||||
'speak': "<s>List what?</s>"
|
||||
},
|
||||
{
|
||||
"type": "Input.ChoiceSet"
|
||||
"id": "gho list (teams|repos|members) - input0"
|
||||
"style": "compact"
|
||||
"value": "teams"
|
||||
"choices": [
|
||||
{
|
||||
"title": "teams"
|
||||
"value": "teams"
|
||||
},
|
||||
{
|
||||
"title": "repos"
|
||||
"value": "repos"
|
||||
},
|
||||
{
|
||||
"title": "members"
|
||||
"value": "members"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
'actions': [
|
||||
{
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': {
|
||||
'queryPrefix': "gho list (teams|repos|members)"
|
||||
"gho list (teams|repos|members) - query0": 'hubot gho list '
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# Action
|
||||
result = null
|
||||
expect(() ->
|
||||
result = HubotResponseCards.maybeConstructMenuInputCard(query)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(result).to.eql(expected)
|
||||
|
||||
describe 'appendCardBody', ->
|
||||
card1 = null
|
||||
card2 = null
|
||||
expected = null
|
||||
beforeEach ->
|
||||
card1 = {
|
||||
'contentType': 'application/vnd.microsoft.card.adaptive'
|
||||
'content': {
|
||||
"type": "AdaptiveCard"
|
||||
"version": "1.0"
|
||||
"body": [
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "Card1"
|
||||
'speak': "<s>Card1</s>"
|
||||
'weight': 'bolder'
|
||||
'size': 'large'
|
||||
},
|
||||
{
|
||||
'type': 'Input.Text'
|
||||
'id': "the-same-id"
|
||||
'speak': "<s>the same text</s>"
|
||||
'wrap': true
|
||||
'style': 'text'
|
||||
},
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "This is unique to 1"
|
||||
'speak': "<s>This is unique to 1</s>"
|
||||
},
|
||||
{
|
||||
"type": "Input.ChoiceSet"
|
||||
"id": "a-selector-unique-to-card1-id"
|
||||
"style": "compact"
|
||||
"choices": [
|
||||
{
|
||||
"title": "Card 1 choice"
|
||||
"value": "Card 1 choice"
|
||||
},
|
||||
{
|
||||
"title": "Another card 1 choice"
|
||||
"value": "Another card 1 choice"
|
||||
}
|
||||
]
|
||||
"value": "Another card 1 choice"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
card2 = {
|
||||
'contentType': 'application/vnd.microsoft.card.adaptive'
|
||||
'content': {
|
||||
"type": "AdaptiveCard"
|
||||
"version": "1.0"
|
||||
"body": [
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "Card2"
|
||||
'speak': "<s>Card2</s>"
|
||||
'weight': 'bolder'
|
||||
'size': 'large'
|
||||
},
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "This is unique to 2"
|
||||
'speak': "<s>This is unique to 2</s>"
|
||||
},
|
||||
{
|
||||
'type': 'Input.Text'
|
||||
'id': "the-same-id"
|
||||
'speak': "<s>the same text</s>"
|
||||
'wrap': true
|
||||
'style': 'text'
|
||||
},
|
||||
{
|
||||
"type": "Input.ChoiceSet"
|
||||
"id": "a-selector-unique-to-card2-id"
|
||||
"style": "compact"
|
||||
"choices": [
|
||||
{
|
||||
"title": "Card 2 choice"
|
||||
"value": "Card 2 choice"
|
||||
},
|
||||
{
|
||||
"title": "Another card 2 choice"
|
||||
"value": "Another card 2 choice"
|
||||
}
|
||||
]
|
||||
"value": "Another card 2 choice"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
expected = {
|
||||
'contentType': 'application/vnd.microsoft.card.adaptive'
|
||||
'content': {
|
||||
"type": "AdaptiveCard"
|
||||
"version": "1.0"
|
||||
"body": [
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "Card1"
|
||||
'speak': "<s>Card1</s>"
|
||||
'weight': 'bolder'
|
||||
'size': 'large'
|
||||
},
|
||||
{
|
||||
'type': 'Input.Text'
|
||||
'id': "the-same-id"
|
||||
'speak': "<s>the same text</s>"
|
||||
'wrap': true
|
||||
'style': 'text'
|
||||
},
|
||||
{
|
||||
'type': 'TextBlock'
|
||||
'text': "This is unique to 1"
|
||||
'speak': "<s>This is unique to 1</s>"
|
||||
},
|
||||
{
|
||||
"type": "Input.ChoiceSet"
|
||||
"id": "a-selector-unique-to-card1-id"
|
||||
"style": "compact"
|
||||
"choices": [
|
||||
{
|
||||
"title": "Card 1 choice"
|
||||
"value": "Card 1 choice"
|
||||
},
|
||||
{
|
||||
"title": "Another card 1 choice"
|
||||
"value": "Another card 1 choice"
|
||||
}
|
||||
]
|
||||
"value": "Another card 1 choice"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
it 'both cards don\'t have bodies, should return card1 unchanged', ->
|
||||
# Setup
|
||||
delete card1.content.body
|
||||
delete card2.content.body
|
||||
delete expected.content.body
|
||||
|
||||
# Action
|
||||
result = null
|
||||
expect(() ->
|
||||
result = HubotResponseCards.appendCardBody(card1, card2)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(result).to.deep.equal(expected)
|
||||
|
||||
it 'card2 doesn\'t have a body, should return card1 unchanged', ->
|
||||
# Setup
|
||||
delete card2.content.body
|
||||
|
||||
# Action
|
||||
result = null
|
||||
expect(() ->
|
||||
result = HubotResponseCards.appendCardBody(card1, card2)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(result).to.deep.equal(expected)
|
||||
|
||||
it 'card1 doesn\'t have a body, result body should equal card2\'s body', ->
|
||||
# Setup
|
||||
delete card1.content.body
|
||||
expected.content.body = card2.content.body
|
||||
|
||||
# Action
|
||||
result = null
|
||||
expect(() ->
|
||||
result = HubotResponseCards.appendCardBody(card1, card2)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(result).to.deep.equal(expected)
|
||||
|
||||
it 'both cards have bodies, should combine both bodies into card1 and remove duplicates', ->
|
||||
# Setup
|
||||
expected.content.body.push({
|
||||
'type': 'TextBlock'
|
||||
'text': "Card2"
|
||||
'speak': "<s>Card2</s>"
|
||||
'weight': 'bolder'
|
||||
'size': 'large'
|
||||
})
|
||||
expected.content.body.push({
|
||||
'type': 'TextBlock'
|
||||
'text': "This is unique to 2"
|
||||
'speak': "<s>This is unique to 2</s>"
|
||||
})
|
||||
expected.content.body.push({
|
||||
"type": "Input.ChoiceSet"
|
||||
"id": "a-selector-unique-to-card2-id"
|
||||
"style": "compact"
|
||||
"choices": [
|
||||
{
|
||||
"title": "Card 2 choice"
|
||||
"value": "Card 2 choice"
|
||||
},
|
||||
{
|
||||
"title": "Another card 2 choice"
|
||||
"value": "Another card 2 choice"
|
||||
}
|
||||
]
|
||||
"value": "Another card 2 choice"
|
||||
})
|
||||
|
||||
# Action
|
||||
result = null
|
||||
expect(() ->
|
||||
result = HubotResponseCards.appendCardBody(card1, card2)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(result).to.deep.equal(expected)
|
||||
|
||||
describe 'appendCardActions', ->
|
||||
card1 = null
|
||||
card2 = null
|
||||
expected = null
|
||||
beforeEach ->
|
||||
card1 = {
|
||||
'contentType': 'application/vnd.microsoft.card.adaptive'
|
||||
'content': {
|
||||
"type": "AdaptiveCard"
|
||||
"version": "1.0"
|
||||
"actions": [
|
||||
{
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': {
|
||||
"a-shared-field": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': {
|
||||
"a-field-card1": "a-value-card1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
card2 = {
|
||||
'contentType': 'application/vnd.microsoft.card.adaptive'
|
||||
'content': {
|
||||
"type": "AdaptiveCard"
|
||||
"version": "1.0"
|
||||
"actions": [
|
||||
{
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': {
|
||||
"a-shared-field": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': {
|
||||
"a-field-card2": "a-value-card2"
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': {
|
||||
"a-shared-field": "shared"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
expected = {
|
||||
'contentType': 'application/vnd.microsoft.card.adaptive'
|
||||
'content': {
|
||||
"type": "AdaptiveCard"
|
||||
"version": "1.0"
|
||||
"actions": [
|
||||
{
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': {
|
||||
"a-shared-field": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': {
|
||||
"a-field-card1": "a-value-card1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
it 'both cards don\'t have actions, should return card1 unchanged', ->
|
||||
# Setup
|
||||
delete card1.content.actions
|
||||
delete card2.content.actions
|
||||
delete expected.content.actions
|
||||
|
||||
# Action
|
||||
result = null
|
||||
expect(() ->
|
||||
result = HubotResponseCards.appendCardActions(card1, card2)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(result).to.deep.equal(expected)
|
||||
|
||||
it 'card2 doesn\'t have actions, should return card1 unchanged', ->
|
||||
# Setup
|
||||
delete card2.content.actions
|
||||
|
||||
# Action
|
||||
result = null
|
||||
expect(() ->
|
||||
result = HubotResponseCards.appendCardActions(card1, card2)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(result).to.deep.equal(expected)
|
||||
|
||||
it 'card1 doesn\'t have actions, result actions should equal card2\'s actions', ->
|
||||
# Setup
|
||||
delete card1.content.actions
|
||||
expected.content.actions = card2.content.actions
|
||||
|
||||
# Action
|
||||
result = null
|
||||
expect(() ->
|
||||
result = HubotResponseCards.appendCardActions(card1, card2)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(result).to.deep.equal(expected)
|
||||
|
||||
it 'both cards have actions, should combine both actions into card1 and remove duplicates', ->
|
||||
# Setup
|
||||
expected.content.actions.push({
|
||||
'type': 'Action.Submit'
|
||||
'title': 'Submit'
|
||||
'speak': '<s>Submit</s>'
|
||||
'data': {
|
||||
"a-field-card2": "a-value-card2"
|
||||
}
|
||||
})
|
||||
|
||||
# Action
|
||||
result = null
|
||||
expect(() ->
|
||||
result = HubotResponseCards.appendCardActions(card1, card2)
|
||||
).to.not.throw()
|
||||
|
||||
# Assert
|
||||
expect(result).to.deep.equal(expected)
|
|
@ -1,3 +1,3 @@
|
|||
--compilers coffee:coffee-script/register
|
||||
--require coffee-coverage/register-istanbul
|
||||
--recursive
|
||||
--recursive
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
class TeamsChatConnector
|
||||
constructor: (options) ->
|
||||
@appId = options.appId
|
||||
@appPassword = options.appPassword
|
||||
|
||||
fetchMembers: (serviceUrl, conversationId, callback) ->
|
||||
members = [
|
||||
{
|
||||
id: 'user-id',
|
||||
objectId: 'eight888-four-4444-fore-twelve121212',
|
||||
name: 'user-name',
|
||||
givenName: 'user-',
|
||||
surname: 'name',
|
||||
email: 'em@ai.l',
|
||||
userPrincipalName: 'em@ai.l'
|
||||
},
|
||||
{
|
||||
id: 'user2-id',
|
||||
objectId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
name: 'user2 two',
|
||||
givenName: 'user2',
|
||||
surname: 'two',
|
||||
email: 'em@ai.l2',
|
||||
userPrincipalName: 'em@ai.l2'
|
||||
}
|
||||
]
|
||||
|
||||
callback false, members
|
||||
|
||||
BotBuilderTeams = {
|
||||
TeamsChatConnector: TeamsChatConnector
|
||||
}
|
||||
|
||||
# module.exports = TeamsChatConnector
|
||||
module.exports = BotBuilderTeams
|
|
@ -4,7 +4,35 @@ class MockRobot
|
|||
@logger =
|
||||
info: ->
|
||||
warn: ->
|
||||
@commands = [
|
||||
"hubot a - does something a"
|
||||
"hubot b - does something b"
|
||||
]
|
||||
@brain =
|
||||
userForId: -> {}
|
||||
data: {}
|
||||
userForId: (id, options) ->
|
||||
user = {
|
||||
id: "#{id}"
|
||||
name: "a-hubot-user-name"
|
||||
}
|
||||
if options is null
|
||||
return user
|
||||
else
|
||||
for key of options
|
||||
user[key] = options[key]
|
||||
return user
|
||||
|
||||
users: -> []
|
||||
get: (key) ->
|
||||
if @data is undefined
|
||||
return null
|
||||
for storedKey of @data
|
||||
if key == storedKey
|
||||
return @data[storedKey]
|
||||
return null
|
||||
set: (key, value) ->
|
||||
@data[key] = value
|
||||
|
||||
receive: (event) ->
|
||||
@brain.data["event"] = event
|
||||
module.exports = MockRobot
|
||||
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
Загрузка…
Ссылка в новой задаче