UNPKG

@microsoft/botbuilder-m365

Version:

M365 extensions for Microsoft BotBuilder, Alpha release.

348 lines (315 loc) 14.3 kB
/* eslint-disable security/detect-object-injection */ /** * @module botbuilder-m365 */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { Plan, PredictedCommand, PredictedDoCommand, PredictedSayCommand } from './Planner'; const BREAKING_CHARACTERS = '`~!@#$%^&*()_+-={}|[]\\:";\'<>?,./ \r\n\t'; const NAME_BREAKING_CHARACTERS = '`~!@#$%^&*()+={}|[]\\:";\'<>?,./ \r\n\t'; const SPACE_CHARACTERS = ' \r\n\t'; const COMMANDS = ['DO', 'SAY']; const DEFAULT_COMMAND = 'SAY'; const IGNORED_TOKENS = ['THEN']; export interface ParsedCommandResult { length: number; command?: PredictedCommand; } export class ResponseParser { public static parseAdaptiveCard(text?: string): Record<string, any> | undefined { const obj = this.parseJSON(text); return obj && obj['type'] === 'AdaptiveCard' ? obj : undefined; } public static parseJSON<T = Record<string, any>>(text?: string): T | undefined { let obj: T | undefined; try { if (text) { const startJson = text.indexOf('{'); const endJson = text.lastIndexOf('}'); if (startJson >= 0 && endJson > startJson) { const txt = text.substring(startJson, endJson + 1); obj = JSON.parse(txt); } } } catch (err) { // no action } return obj; } public static parseResponse(text?: string): Plan { // See if the response contains a plan object? let plan: Plan = this.parseJSON(text) as Plan; if (plan && plan.type?.toLowerCase() === 'plan') { plan.type = 'plan'; if (!Array.isArray(plan.commands)) { plan.commands = []; } return plan; } // Parse response using DO/SAY syntax let responses = ''; plan = { type: 'plan', commands: [] }; let tokens = this.tokenizeText(text); if (tokens.length > 0) { // Insert default command if response doesn't start with a command if (COMMANDS.indexOf(tokens[0]) < 0) { tokens = [DEFAULT_COMMAND].concat(tokens); } while (tokens.length > 0) { // Parse command let result: ParsedCommandResult; switch (tokens[0]) { case 'DO': result = this.parseDoCommand(tokens); break; case 'SAY': default: result = this.parseSayCommand(tokens); break; } // Did we get a command back? if (result.length > 0) { // Add command to list if generated // - In the case of `DO DO command` the first DO command wouldn't generate if (result.command) { if (result.command.type == 'SAY') { // Check for duplicate SAY const response = (result.command as PredictedSayCommand).response.trim().toLowerCase(); if (responses.indexOf(response) < 0) { responses += ' ' + response; plan.commands.push(result.command); } } else { plan.commands.push(result.command); } } // Remove consumed tokens tokens = result.length < tokens.length ? tokens.slice(result.length) : []; } else { // Ignore remaining tokens as something is malformed tokens = []; } } } return plan; } public static parseDoCommand(tokens: string[]): ParsedCommandResult { let length = 0; let command: PredictedDoCommand | undefined; if (tokens.length > 1) { if (tokens[0] != 'DO') { throw new Error(`ResponseParse.parseDoCommand(): token list passed in doesn't start with 'DO' token.`); } // Parse command (skips initial DO token) let actionName = ''; let entityName = ''; let entityValue = ''; let quoteType = ''; let parseState: DoCommandParseState = DoCommandParseState.findActionName; while (++length < tokens.length) { // Check for ignored tokens const token = tokens[length]; if (IGNORED_TOKENS.indexOf(token) >= 0) { continue; } // Stop processing if a new command is hit // - Ignored if in a quoted string if (COMMANDS.indexOf(token) >= 0 && parseState != DoCommandParseState.inEntityStringValue) { break; } // Check for beginning of another command switch (parseState as DoCommandParseState) { case DoCommandParseState.findActionName: default: // Ignore leading breaking characters if (BREAKING_CHARACTERS.indexOf(token) < 0) { // Assign token to action name and enter new state actionName = token; parseState = DoCommandParseState.inActionName; } break; case DoCommandParseState.inActionName: // Accumulate tokens until you hit a breaking character // - Underscores and dashes are allowed if (NAME_BREAKING_CHARACTERS.indexOf(token) >= 0) { // Initialize command object and enter new state command = { type: 'DO', action: actionName, entities: {} }; parseState = DoCommandParseState.findEntityName; } else { actionName += token; } break; case DoCommandParseState.findEntityName: // Ignore leading breaking characters if (BREAKING_CHARACTERS.indexOf(token) < 0) { // Assign token to entity name and enter new state entityName = token; parseState = DoCommandParseState.inEntityName; } break; case DoCommandParseState.inEntityName: // Accumulate tokens until you hit a breaking character // - Underscores and dashes are allowed if (NAME_BREAKING_CHARACTERS.indexOf(token) >= 0) { // We know the entity name so now we need the value parseState = DoCommandParseState.findEntityValue; } else { entityName += token; } break; case DoCommandParseState.findEntityValue: // Look for either string quotes first non-space or equals token if (token == '"' || token == "'" || token == '`') { // Check for content value if (token == '`' && tokens[length + 1] == '`' && tokens[length + 2] == '`') { length += 2; parseState = DoCommandParseState.inEntityContentValue; } else { // Remember quote type and enter new state quoteType = token; parseState = DoCommandParseState.inEntityStringValue; } } else if (SPACE_CHARACTERS.indexOf(token) < 0 && token != '=') { // Assign token to value and enter new state entityValue = token; parseState = DoCommandParseState.inEntityValue; } break; case DoCommandParseState.inEntityStringValue: // The following code is checking that the tokens are matching and is not exposing sensitive data // Accumulate tokens until end of string is hit // eslint-disable-next-line security/detect-possible-timing-attacks if (token === quoteType) { // Save pair and look for additional pairs command!.entities[entityName] = entityValue; parseState = DoCommandParseState.findEntityName; entityName = entityValue = ''; } else { entityValue += token; } break; case DoCommandParseState.inEntityContentValue: if (token == '`' && tokens[length + 1] == '`' && tokens[length + 2] == '`') { // Save pair and look for additional pairs length += 2; command!.entities[entityName] = entityValue; parseState = DoCommandParseState.findEntityName; entityName = entityValue = ''; } else { entityValue += token; } break; case DoCommandParseState.inEntityValue: // Accumulate tokens until you hit a space if (SPACE_CHARACTERS.indexOf(token) >= 0) { // Save pair and look for additional pairs command!.entities[entityName] = entityValue; parseState = DoCommandParseState.findEntityName; entityName = entityValue = ''; } else { entityValue += token; } break; } } // Create command if not created // - This happens when a DO command without any entities is at the end of the response. if (!command && actionName) { command = { type: 'DO', action: actionName, entities: {} }; } // Append final entity if (command && entityName) { command.entities[entityName] = entityValue; } } return { length, command }; } public static parseSayCommand(tokens: string[]): ParsedCommandResult { let length = 0; let command: PredictedSayCommand | undefined; if (tokens.length > 1) { if (tokens[0] != 'SAY') { throw new Error( `ResponseParse.parseSayCommand(): token list passed in doesn't start with 'SAY' token.` ); } // Parse command (skips initial DO token) let response = ''; while (++length < tokens.length) { // Check for ignored tokens const token = tokens[length]; if (IGNORED_TOKENS.indexOf(token) >= 0) { continue; } // Stop processing if a new command is hit if (COMMANDS.indexOf(token) >= 0) { break; } // Append token to output response response += token; } // Create command if (response.length > 0) { command = { type: 'SAY', response: response }; } } return { length, command }; } /** * Simple text tokenizer. Breaking characters are added to list as separate tokens. * * @param {string} text Optional. Text string to tokenize. * @returns {[string]} Array of tokens. */ public static tokenizeText(text?: string): string[] { const tokens: string[] = []; if (text) { let token = ''; const len = text.length; for (let i = 0; i < len; i++) { const ch = text[i]; if (BREAKING_CHARACTERS.indexOf(ch) >= 0) { // Push token onto list if (token.length > 0) { tokens.push(token); } // Push breaking character onto list as a separate token tokens.push(ch); // Start a new empty token token = ''; } else { // Add to existing token token += ch; } } // Push last token onto list if (token.length > 0) { tokens.push(token); } } return tokens; } } enum DoCommandParseState { findActionName, inActionName, findEntityName, inEntityName, findEntityValue, inEntityValue, inEntityStringValue, inEntityContentValue }