-
Notifications
You must be signed in to change notification settings - Fork 15
Add support for multiple attachments #8
base: master
Are you sure you want to change the base?
Changes from 7 commits
04999f6
b86b418
a748ad2
ac0dd3b
65f1108
dd834c8
8c6ea46
ed52f83
310ac14
09c3978
9c34c6d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,7 +16,7 @@ concise, simple test files with strong flexibility and extensibility. | |
- Parallel execution of tests | ||
- Test against multiple acceptable answers | ||
- Wildcard matching (`<NUMBER>`, `<WORD>`, `<*>`) | ||
- Support for testing rich attachments (`<IMAGE>`, `<CARDS>`) | ||
- Support for testing rich attachments (`<IMAGE>`, `<CARDS>`, `<OTHER>`) | ||
- Support for regular expressions (`<(st|nt|nd)>`) | ||
- Show diffs on error | ||
- Conversation branches | ||
|
@@ -71,19 +71,39 @@ Dialogue: | |
- Bot: <WORD> | ||
``` | ||
|
||
Handling multiple attachments | ||
|
||
```YAML | ||
Title: Conversation with rich messages | ||
Dialogue: | ||
- Bot: Hi | ||
- Human: Hello | ||
- Human: How are we doing this month? | ||
- Bot: "Month-to-date: <IMAGE> <CARDS>" | ||
- Human: Show me ad spend compared to last month | ||
- Bot: <IMAGE> <IMAGE> | ||
``` | ||
|
||
## Special Tags | ||
|
||
You can use the following tags in your test dialogue files: | ||
You can use the following tags anywhere in the bot’s response: | ||
|
||
Tag | Meaning | ||
--- | --- | ||
`<*>` | Matches anything, including whitespaces | ||
`<WORD>` | A single word without whitespaces | ||
`<IMAGE>` | An image attachment | ||
`<CARDS>` | A card attachment | ||
`<(REGEX)>` | Any regex expression, i.e. `<([0-9]{2})>` | ||
`<$VARNAME>` | An expression from the locale/translation file | ||
|
||
You can match one or more attachments **on their own, or at the end** of the | ||
response: | ||
|
||
Tag | Meaning | ||
--- | --- | ||
`<CARDS>` | Any card attachment | ||
`<IMAGE>` | Any image attachment | ||
`<OTHER>` | Any other type of attachment | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add some examples below here. |
||
|
||
## Install | ||
|
||
To install from npm | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,7 @@ | ||
'use strict'; | ||
import { Client } from './clients/client_interface'; | ||
import { Dialogue } from './spec/dialogue'; | ||
import { Message, MessageType } from './spec/message'; | ||
import { Message, Attachment } from './spec/message'; | ||
import { Response } from './spec/response'; | ||
import { Turn, TurnType } from './spec/turn'; | ||
import { Config } from './config'; | ||
|
@@ -156,7 +156,7 @@ export class Runner { | |
if (response !== null) { | ||
if (program.verbose) { | ||
const timing = (new Date().getTime() - this.timing) || 0; | ||
if (response.messageTypes.includes(MessageType.Text)) { | ||
if (response.attachments.length === 0) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems wrong? Should be
I know this is not how it is before but this would bring it into consistency with the new formatting style. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah good catch, thanks. |
||
const texts = response.text.split('\n'); | ||
const truncatedText = texts.length > 5 | ||
? texts.slice(0, 5).join('\n') + '...' : response.text; | ||
|
@@ -261,7 +261,7 @@ export class Runner { | |
} | ||
this.client.send({ | ||
user, | ||
messageTypes: [MessageType.Text], | ||
attachments: [], | ||
text: next.query, | ||
}); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,18 @@ | ||
export enum MessageType { | ||
Text = 'Text', | ||
Image = 'Image', | ||
Card = 'Card', | ||
/* | ||
* An enumeration of the possible attachment types. | ||
* | ||
* `Other` is a catch-all for any attachment type that is not (yet) explicitly | ||
* supported. The enum values are used to build the regexes for the attachment | ||
* wildcards, e.g. <IMAGE> is derived from 'IMAGE'. | ||
*/ | ||
export enum Attachment { | ||
Image = 'IMAGE', | ||
Cards = 'CARDS', | ||
Other = 'OTHER', | ||
} | ||
|
||
export interface Message { | ||
messageTypes: MessageType[]; | ||
attachments: Attachment[]; | ||
text: string; | ||
user: string; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,50 +1,80 @@ | ||
import { Message, MessageType } from './message'; | ||
import { Message, Attachment } from './message'; | ||
import { Translator } from '../translator'; | ||
import * as program from 'commander'; | ||
|
||
const wildcardRegex: RegExp = /<\*>/g; | ||
const wordRegex: RegExp = /<WORD>/g; | ||
const numberRegex: RegExp = /<NUMBER>/g; | ||
const imageRegex: RegExp = /<IMAGE>/g; | ||
const cardsRegex: RegExp = /<CARDS>/g; | ||
const regexRegex: RegExp = /<\((.+?)\)>/g; | ||
|
||
// Matches an attachment wildcard (e.g. <IMAGE>) at the _end_ of responseData. | ||
// | ||
// The matched part is just the word in the brackets ('IMAGE'), which can be | ||
// directly compared against the Attachment enum (from which it was generated). | ||
const attachmentAlternatives = Object.values(Attachment).join('|'); | ||
const attachmentsRegex = new RegExp(`\\s*<(${attachmentAlternatives})>$`); | ||
|
||
export class Response { | ||
responseType: MessageType; | ||
attachments: Attachment[]; | ||
private textMatchChecker: RegExp; | ||
private bodyText: string; | ||
original: string; | ||
|
||
constructor(responseData: string) { | ||
this.attachments = []; | ||
this.original = responseData.trim(); | ||
switch (this.original) { | ||
case '<IMAGE>': | ||
this.responseType = MessageType.Image; | ||
break; | ||
case '<CARDS>': | ||
this.responseType = MessageType.Card; | ||
break; | ||
default: | ||
this.responseType = MessageType.Text; | ||
this.textMatchChecker = new RegExp(Response.transformTags(this.original)); | ||
this.bodyText = this.original; | ||
|
||
// Construct a list of attachments from the attachment wildcards at the | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like this solution, it's nice. Though I wonder why you constrain it to happening at the end of the line? Seems a bit artificial to me? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In principle they could go anywhere, but I’m not sure what the use case would be. For example: Dialogue:
- Human: How is my campaign doing?
- Bot: According to <IMAGE> and <IMAGE>, it’s going great! This doesn’t make any sense in bot framework per se, because it doesn’t define a mechanism for embedding attachments inline with the text. Presumably you would expect You can imagine a client which does support this (e.g. one that uses There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually you are right that the botbuilder platform specifically limits this ordering. However, for other platforms like Slack, it is allowed to have intermingled text + attachments, so for that reason it might be good to allow the tags anywhere, and just concatenate the text. It could lead to this edge case where someone sends
and it validates against:
However at the same time, we could introduce this fix as a separate PR as you don't have any example connector right now, so it would be hard to test. For now we can concatenate and it will work for most practical use cases I think. So to summarize I think for now a good compromise is to just drop the ordering condition (remove the |
||
// end of the response data. The end result is that `this.bodyText` | ||
// contains only the body text and potentially some inline wildcards; | ||
// and `this.attachments` contains a list of attachment types, in the | ||
// same order as they originally were in the supplied data. | ||
let match; | ||
while ((match = this.bodyText.match(attachmentsRegex)) != null) { | ||
this.attachments.push(match[1]); | ||
this.bodyText = this.bodyText.slice(0, match.index); | ||
} | ||
this.attachments.reverse(); | ||
|
||
// Construct a regex to match the remaining body text in terms of its | ||
// inline wildcards. | ||
this.textMatchChecker = new RegExp(Response.transformTags(this.bodyText)); | ||
} | ||
|
||
matches(message: Message): boolean { | ||
if (!message.messageTypes.includes(this.responseType)) { | ||
|
||
// If we are expecting a message body, check that we have one... | ||
if (this.bodyText && !message.text) { | ||
return false; | ||
} else if (this.responseType === MessageType.Text) { | ||
return message.text.trim().match(this.textMatchChecker) !== null; | ||
} else { | ||
return true; | ||
} | ||
|
||
// ...and check that the content is the same. | ||
// | ||
// Note that we ignore the message body altogether if the spec doesn't | ||
// include one. This is for backwards compatibility with when <IMAGE> | ||
// and <CARDS> could only be used in place of the entire message. | ||
if (this.bodyText && message.text.trim().match(this.textMatchChecker) === null) { | ||
return false; | ||
} | ||
|
||
// Check that we have the same number, kind, and order of attachments. | ||
// We can use JSON.stringify here since we know attachments is just a | ||
// simple array of strings. | ||
|
||
// Check that we have the right number of attachments. | ||
if (JSON.stringify(this.attachments) !== JSON.stringify(message.attachments)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stringify has this tricky behavior where ordering of keys is only guaranteed in Node environment IF the object is the same (same pointer). Even if two objects look the same, they will not necessarily get stringified in the same way. Use https://github.com/substack/json-stable-stringify to do a sort before the stringify. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting. I assumed that stringifying an Array<String> would be safe. If you like I can just revert ac0dd3b to avoid bringing in another library? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah that sounds like a good idea |
||
return false; | ||
} | ||
|
||
// Everything appears to be in order. | ||
return true; | ||
} | ||
|
||
static transformTags(text: string): string { | ||
regexRegex.lastIndex = 0; | ||
|
||
const taggedText = text | ||
.replace(imageRegex, '') | ||
.replace(cardsRegex, '') | ||
.replace(wildcardRegex, '<([\\s\\S]*?)>') | ||
.replace(wordRegex, '<([^ ]+?)>') | ||
.replace(numberRegex, '<([0-9\.,-]+)>'); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the docs here. I would just add a comment here that includes buttons, as this might not be obvious to them.