Brian Bauer
Brian Bauer

Reputation: 135

Is it possible to integrate bot-builder into an existing express app?

I have an existing node/express chatbot application that connects to several chat platforms using ExpressJS' next(), next() middleware design pattern. I send a 200 response at the very beginning to acknowledge the receipt of a message, and send a new POST request to send a message from my last middleware.

app.post("/bots", receiveMsg, doStuff, formatAndSendMsg, catchErrors);

Now I want to integrate Skype as a channel for my bot, but the NodeJS library for bot-framework has a totally different way of doing things, using events and such magic that I haven't fully understood yet:

var connector = new builder.ConsoleConnector();
app.post("/skype", connector.listen());
var bot = new builder.UniversalBot(connector, function (session) {
    session.send("You said: %s", session.message.text);
});

It doesn't look like these are compatible ways to do things, so what is the best way to receive a message and then send a response to a user without having to change my express routing to fit bot-builder in? Can I get a Session object with Session.send() to respond to? Will I have to do all the addressing manually? Is there a method that resembles this:

app.post("/skype", (req, res, next) => {
    const address = req.body.id;
    const message = new builder.Message(address, messageBody).send()
}

Or:

app.post("/skype", connector.listen(), (req, res, next) => {
    // (res.locals is available in every express middleware function)
    const session = res.locals.botFrameworkSession;
    // do stuff
    session.send(message);
}

Upvotes: 0

Views: 798

Answers (2)

Brian Bauer
Brian Bauer

Reputation: 135

Building messages, addressing them, and sending those messages are all possible using the official bot framework NodeJS library. What I couldn't do with that library was receive messages and verify their authenticity on my routes without making major changes to my design (using request middleware - next() - to process the incoming request) which is already in production with other bots and not easy to change.

Here's my workaround: First is this BotFrameworkAuthenticator class that I create based on the Microsoft documentation here: https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication It is instantiated with the appID and appPassword from your Bot Framework app.

import axios from "axios";
import * as jwt from "jsonwebtoken";
import * as jwkToPem from 'jwk-to-pem';

export class BotFrameworkAuthenticator {
    private appId: string;
    private appPassword: string;
    private openIdMetadata: any;
    // The response body specifies the document in the JWK format but also includes an additional property for each key: endorsements.
    private validSigningKeys: any;
    // The list of keys is relatively stable and may be cached for long periods of time (by default, 5 days within the Bot Builder SDK).
    private signingKeyRefreshRate: number = 432000; // in seconds (432000 = 5 days)

    constructor(appId, appPassword) {
        this.appId = appId;
        this.appPassword = appPassword;
        this.getListOfSigningKeys();
    }

    // response data should contain "jwks_uri" property that contains address to request list of valid signing keys.
    public async getOpenIdMetaData() {
        // This is a static URL that you can hardcode into your application. - MS Bot Framework docs
        await axios.get("https://login.botframework.com/v1/.well-known/openidconfiguration").then(response => {
            this.openIdMetadata = response.data;
            logger.info("OpenID metadata document recieved for Bot Framework.");
        }).catch(err => {
            logger.warn(err.message, "Could not get OpenID metadata document for Bot Framework. Retrying in 15 seconds...");
            setTimeout(this.getListOfSigningKeys, 15000);
        })
    }

    public async getListOfSigningKeys() {
        await this.getOpenIdMetaData();
        if (this.openIdMetadata && this.openIdMetadata.jwks_uri) {
            // previous function getOpenIdMetaData() succeeded
            await axios.get(this.openIdMetadata.jwks_uri).then(response => {
                logger.info(`Signing keys recieved for Bot Framework. Caching for ${this.signingKeyRefreshRate / 86400} days.`);
                this.validSigningKeys = response.data.keys;
                setTimeout(this.getListOfSigningKeys, (this.signingKeyRefreshRate * 1000));
            }).catch(err => {
                logger.error(err.message, "Could not get list of valid signing keys for Bot Framework. Retrying in 15 seconds");
                setTimeout(this.getListOfSigningKeys, 15000);
            });
        } else {
            // previous function getOpenIdMetaData() failed, but has already queued this function to run again. Will continue until succeeds.
            return;
        }
    }

    /**
     * Verifies that the message was sent from Bot Framework by checking values as specified in Bot Framework Documentation:
     * https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication#step-4-verify-the-jwt-token
     * Retrieves the Bearer token from the authorization header, decodes the token so we can match the key id (kid) to a key in the OpenID
     * document, then converts that key to PEM format so that jwt/crypto can consume it to verify that the bearer token is
     * cryptographically signed.
     * If the serviceUrl property in the token doe not match the serviceUrl property in the message, it should also be rejected.
     */
    public verifyMsgAuthenticity(serviceUrl: string, headers: any) {
        try {
            const token = headers.authorization.replace("Bearer ", "");
            const decoded = jwt.decode(token, { complete: true }) as any;
            const verifyOptions = {
                issuer: "https://api.botframework.com",
                audience: this.appId,
                clockTolerance: 300, // (seconds) The token is within its validity period. Industry-standard clock-skew is 5 minutes. (Bot Framework documentation);
            }
            const jwk = this.lookupKey(decoded.header.kid)
            const pem = jwkToPem(jwk);
            const verified = jwt.verify(token, pem, verifyOptions) as any;
            if (!serviceUrl || serviceUrl !== verified.serviceurl) {
                logger.warn("Non-matching serviceUrl in Bot Framework verified token!")
                return false;
            }
            return true;
        } catch (err) {
            logger.warn("Received invalid/unsigned message on Bot Framework endpoint!", err.message)
            return false;
        }
    }

    // Finds the relevant key from the openID list. Does not transform the key.
    private lookupKey(kid) {
        const jwk = this.validSigningKeys.find((key) => {
            return (key.kid === kid);
        });
        return jwk;
    }
}

Use the BotFrameworkAuthenticator class like this at the very beginning of your express route to verify that all incoming requests are valid.

const botFrameworkAuthenticator = new BotFrameworkAuthenticator(appID, appPassword);

router.post("/", (req: Request, res: Response, next: NextFunction) => {
    if (botFrameworkAuthenticator.verifyMsgAuthenticity(req.body.serviceUrl, req.headers) === true) {
        res.status(200).send();
        next();
    } else {
        // unsafe to process
        res.status(403).send();
        return;
    }
});

And to send messages using the regular Bot Framework library without having a Session object that would normally be created by the Bot Framework library when it receives an incoming message:

import * as builder from "botbuilder";

// instantiate the chatConnector (only once, not in the same function as the sending occurs)
const botFrameworkSender = new builder.ChatConnector({ appId, appPassword });

//---------------------------------------------

const skypeMsg = req.body;
const address = {
    channelId: skypeMsg.channelId,
    user: skypeMsg.from,
    bot: skypeMsg.recipient,
    conversation: skypeMsg.conversation
};
const response = new builder.Message().text(someText).address(address).toMessage();
const formattedResponses = [response];
botFrameworkSender.send(formattedResponses, logErrorsToConsole);

Note that all of the builder.Message() -- .attachment(), .images(), etc.. -- functions can be used, not just the text()

Upvotes: 0

Gary Liu
Gary Liu

Reputation: 13918

You can register bot application in your existing express applications. Bot builder SDK is also compatible in expressjs framework. You can refer to official sample which is also leveraging express.

Don't forget to modify the messsaging endpoint in your bot registration to your bot's endpoint, e.g.

https://yourdomain/stuff

in your scenario. Please refer to https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration for more info.

Upvotes: 1

Related Questions