DEV Community

loading...

Obstacles encountered in the Messenger chatbot development

Željko Šević
Node.js developer with a Computer Science background focused on back-end web development
Originally published at sevic.dev Updated on ・4 min read

I have been working on a Messenger chatbot as a side project for the last couple of months. Tech-stack I'm using on it includes Node.js with TypeScript, NestJS as back-end framework, Bottender as chatbot framework, Redis for session storage, and TypeORM with PostgreSQL as the main database. This blog post covers some of the obstacles encountered in the development process and their solutions or workarounds.

Preventing malicious requests to the webhook endpoint

Signature verification helps to prevent malicious requests, it is a mechanism that checks if requests made to the Messenger webhook URL are genuine. HTTP request should contain an X-Hub-Signature header which contains the SHA1 signature of the request payload, using the app secret as the key and prefixed with sha1=. Bottender provides signature verification out-of-the-box.

// src/common/guards/signature-verification.guard.ts
@Injectable()
export class SignatureVerificationGuard implements CanActivate {
  constructor(private readonly configService: ConfigService) {}

  canActivate(context: ExecutionContext): boolean {
    const {
      rawBody,
      headers: { 'x-hub-signature': signature },
    } = context.switchToHttp().getRequest();
    const { sha1 } = parse(signature);
    if (!sha1) return false;

    const appSecret = this.configService.get('MESSENGER_APP_SECRET');
    const digest = createHmac('sha1', appSecret).update(rawBody).digest('hex');
    const hashBufferFromBody = Buffer.from(`sha1=${digest}`, 'utf-8');
    const bufferFromSignature = Buffer.from(signature, 'utf-8');

    if (hashBufferFromBody.length !== bufferFromSignature.length)
      return false;

    return timingSafeEqual(hashBufferFromBody, bufferFromSignature);
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/modules/webhook/webhook.controller.ts
@UseGuards(SignatureVerificationGuard)
@Post()
@HttpCode(HttpStatus.OK)
handleWebhook(@Body() data) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Communication between Messenger extension and chatbot

For some complicated inputs from the user such as a datetime picker, it is recommended to use a Messenger extension with a webview, where webpages can be loaded inside the Messenger app. The extension webpage should be protected with a CSRF token to prevent malicious requests. Request from the extension to the chatbot should be transformed and signed inside a middle endpoint (to avoid exposing app secret in a webview webpage) and sent to the webhook endpoint.

User's location

User can share locations as an attachment but that doesn't guarantee the location is one where the user is located. Messenger deprecated quick reply for sharing user's location. One workaround would be to get the user's location with the Messenger extension. This solution works only with the Messenger app since Facebook and Messenger websites don't allow sharing location within iframes. Data can be filtered by postgis extension for a specific radius based on the user's location.

Timezones

Showing the datetime in the right timezone

Datetimes are stored in UTC format in the database. Since chatbots can be used across different timezones, the default timezone should be set to UTC so the chatbot can show the right datetime for the corresponding timezone. Date object will use UTC as default timezone if the environment variable TZ has a value UTC. The snippet below sets datetime with the right timezone, it implies that the environment variable TZ is set correctly.

import { utcToZonedTime } from 'date-fns-tz';

const zonedTime = utcToZonedTime(datetime, timezone).toLocaleDateString(locale, options );
Enter fullscreen mode Exit fullscreen mode

Timezone column format

Messenger sends the user's timezone as a number relative to GMT. Most of the libraries use timezone in the IANA timezone name format. To avoid mapping all of the timezones with their offsets, the user's timezone (when the user sends the location) can be gotten by using geo-tz package.

import geoTz from 'geo-tz';

// ...
const timezone = geoTz(latitude, longitude);
// ...
Enter fullscreen mode Exit fullscreen mode

Multi-language chatbot, internationalization

Three independent parts of the chatbot should be internationalized. The first part is chatbot locale based on a user's language. i18n package is used in this project as a dynamic module, it supports the advanced message format which can process the messages based on gender and singular/plural words. The other two parts are provided by Messenger API, persistent menu, and greeting text. Persistent menu and greeting text could be shown in different languages based on which language the user uses, locale property configures persistent menu and greeting text for the specific language.

export const GREETING_TEXT: MessengerTypes.GreetingConfig[] = [
  {
    locale: 'en_US',
    text: greetingText,
  },
  // ...
  {
    locale: 'default',
    text: greetingText,
  },
];
export const PERSISTENT_MENU: MessengerTypes.PersistentMenu = [
  {
    locale: 'en_US',
    callToActions: persistentMenu,
    composerInputDisabled: false,
  },
  // ...
  {
    locale: 'default',
    callToActions: persistentMenu,
    composerInputDisabled: false,
  },
];
Enter fullscreen mode Exit fullscreen mode

Some of the supported locales aren't synchronized across the Facebook website and Messenger app. If the Messenger app doesn't support some language it will use en_US as the default locale.

Sessions

Session state is the temporary data regarding the corresponding conversation. Bottender supports several drivers for session storage (memory, file, Redis and MongoDB) by default.

// ...
context.setState({
  counter: 0,
});
// ...
context.resetState();
// ...
Enter fullscreen mode Exit fullscreen mode

Parsing payloads

A payload can contain several parameters, so it could follow a query string format and be parsed with parse function from querystring module.

import { parse } from 'querystring';
// ...
const buttons = [{
  type: 'postback',
  title,
  payload: `type=${TYPE}&id=${ID}`,
}];
// ...
handlePostback = async (context: MessengerContext) => {
  const { type, id } = parse(context.event.postback.payload);
  switch (type) {
    // ...
  }
  // ...
};
Enter fullscreen mode Exit fullscreen mode

Setting up Messenger profile

Messenger profile allows to set up the persistent menu, greeting text, get started payload, Messenger extensions domain whitelist. Bottender (1.4) doesn't support a custom GraphAPI version, it supports 6.0 by default so it has some restrictions regarding persistent menu buttons number. GraphAPI version 8 allows a persistent menu with up to 20 buttons, so this has to be handled with a script.

// scripts/set-messenger-profile.ts
import { MessengerClient } from 'messaging-api-messenger';

const client = new MessengerClient({
  // ...
  version: '8.0',
});

client
  .setMessengerProfile({
    getStarted: {
      payload: GET_STARTED_PAYLOAD,
    },
    greeting: GREETING_TEXT,
    persistentMenu: PERSISTENT_MENU,
    whitelistedDomains: [process.env.MESSENGER_EXTENSIONS_URL],
  })
// ...
Enter fullscreen mode Exit fullscreen mode

Bottender with custom NestJS server

Bottender calls handler every time the message is received. bootstrap and handler should use the same application instance across the service.

// src/index.ts
export default async function handler() {
  const app = await application.get();
  const chatbotService = app
    .select(BotsModule)
    .get(BotsService, { strict: true });

  return chatbotService.getRouter();
}
Enter fullscreen mode Exit fullscreen mode
// src/main.ts
async function bootstrap(): Promise<void> {
  const app = await application.get();
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Setup for development environment

Ngrok creates a secure public URL pointing to the local server while Bottender enables webhooks integrations.

npx ngrok http 3000
npm run messenger-webhook:set <NGROK_URL>/<WEBHOOK_ENDPOINT>
Enter fullscreen mode Exit fullscreen mode

Discussion (0)