Merge pull request #29 from MSMeMend/master

Optional authorization and cards for Hubot used with MS Teams
This commit is contained in:
Matt Stankiewicz 2018-08-28 10:30:14 -07:00 коммит произвёл GitHub
Родитель 8d0cf959ff 2dc57e15d3
Коммит a42662c1d2
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
15 изменённых файлов: 4942 добавлений и 531 удалений

Просмотреть файл

@ -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

1943
package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -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
@ -43,6 +49,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,33 +304,89 @@ 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)
# 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)
# Remove leading/trailing whitespace and newlines
activity.text = activity.text.trim()
# 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
@ -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, "&lt;")
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)

63
test/adapter.test.coffee Normal file
Просмотреть файл

@ -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)

Просмотреть файл

@ -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

Разница между файлами не показана из-за своего большого размера Загрузить разницу