チャネルトークAPIをNext.js + TypeScript環境で型安全に扱う

フロントエンドエンジニアの中村@taroodrです。

今回は、弊社のhinataレンタルというキャンプ用品のレンタルサービスで導入しているチャネルトークでチャットボットを開発した話をします。

チャットボットを作った背景は、弊社の菅谷がnoteに詳細を記載しているので気になった方はこちらも合わせてご覧ください。

note.com

目次

チャネルトークとは

熱狂的ファンを作るための顧客コミュニケーションツール

を掲げたチャット・マーケティングツールです。

ユーザ数による課金体系や、充実したコミュニティがなど 弊社のようなベンチャー企業には嬉しいポイントがたくさんあります。

channel.io

チャネルトークAPIについて

チャネルトークでは開発者用のAPIが豊富に公開されており、独自のカスタマイズを加えた機能を実装することができます。 また、APIの仕様がOpenAPIで定義されているため型情報を生成することができ、レスポンスに型を付与することが可能なため型安全に利用することが可能です。

https://api-doc.channel.io/

チャネルトークAPIをTypeScriptで型安全に扱う

では実装方法を見ていきます。

OpenAPI定義のダウンロードとコード生成

まずはAPIの定義が書かれた jsonをダウンロードします。

ダウンロードしたAPI定義をもとにクライアント側のコードを生成します。

https://github.com/OpenAPITools/openapi-generator を使うと各種言語のコードが生成できます。 サポートされている言語も多いです。

ActionScript, Ada, Apex, Bash, C, C# (.net 2.0, 3.5 or later, .NET Standard 1.3 - 2.0, .NET Core 2.0), C++ (cpp-restsdk, Qt5, Tizen), Clojure, Dart (1.x, 2.x), Elixir, Elm, Eiffel, Erlang, Go, Groovy, Haskell (http-client, Servant), Java (Jersey1.x, Jersey2.x, OkHttp, Retrofit1.x, Retrofit2.x, Feign, RestTemplate, RESTEasy, Vertx, Google API Client Library for Java, Rest-assured, Spring 5 Web Client, MicroProfile Rest Client), k6, Kotlin, Lua, Nim, Node.js/JavaScript (ES5, ES6, AngularJS with Google Closure Compiler annotations, Flow types, Apollo GraphQL DataStore), Objective-C, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust (rust, rust-server), Scala (akka, http4s, scalaz, sttp, swagger-async-httpclient), Swift (2.x, 3.x, 4.x, 5.x), Typescript (AngularJS, Angular (2.x - 8.x), Aurelia, Axios, Fetch, Inversify, jQuery, Node, Rxjs)

とりあえずインストールします。

$ yarn add -D @openapitools/openapi-generator-cli

今回はTypeScriptを使うので下記のコマンドでコードを生成します。

openapi-generator generate -i oapi/channel-swagger.json -g typescript-fetch -o oapi

-g オプション(GENERATOR)を typescript-fetch にすることで、TypeScriptでfetch apiを使うコードが生成されます。 他の言語やライブラリ用のコードを生成する場合は -g オプションを変更するとOK。 指定できるGENERATORはこちらに一覧があります。

生成されたコードの構成

├── apis
├── swagger.json
├── config.ts
├── index.ts
├── models
└── runtime.ts

TypeScriptエラーの解消

生成されたコードを使って、実装を進めようと思ったところコード内に使用されていない変数があり、tsのエラーが出ていることに気づきました。

生成されたコードに、弊社で利用しているtsconfigのルールだとエラーになる記述が入っていたのです。

// エラーになっていたルール
"noUnusedLocals": true,
"noUnusedParameters": true,

これを解決するために、生成されるコードのもとになるテンプレートを変更することにします。

custom-tmplディレクトリを作成し、独自のテンプレートを記載。 tsのチェックの対象外になるよう ts-nocheck を追加。

├── custom-tmpl
│   ├── apis.mustache
│   └── models.mustache

// copy from https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator/src/main/resources/typescript-fetch/apis.mustache
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck // 追加
{{>licenseInfo}}

そしてコード生成コマンドに -t オプションでテンプレートを指定。

openapi-generator generate -i oapi/channel-swagger.json -t oapi/custom-tmpl -g typescript-fetch -o oapi

これでエラーが解消できます。

Next.jsのAPI Route

hinataレンタルでは、BFFとしてGraphQLを使っているのですが今回の用途は検証目的で高速で実装したかったため、クライアント側で使っているNext.jsのAPI Route機能を使うことにします。

API Routeは pages/api/user.js のように pages/api ディレクトリ以下にファイルをつくり、下記のようにreq, resを引数にとる関数を実装するだけでAPIが作れる機能です。 今回のようにちょっとした検証などに利用する場合は手軽に実装できて便利です。

export default (req, res) => {
  res.statusCode = 200
  res.setHeader('Content-Type', 'application/json')
  res.end(JSON.stringify({ name: 'John Doe' }))
}

実装イメージ

最終的な目標は、ボタンのクリック等ユーザが何らかのアクションをした際に、チャットを開きユーザにメッセージを届けることとします。 ↓実際の画面のイメージは下記になります。 チャネルトークボット実装イメージ

これを実現するためには、

  • 特定のユーザに紐づく新しいチャットを作成する
  • 作成したチャットにメッセージを送信する

機能の実装が必要です。 それぞれ下記のAPIを使って実現します。

  • /open​/v3​/users​/{userId}​/user-chats
  • /open/user_chats/{userChatId}/messages

※March 13th, 2021にサポートが切れるLegacy OpenApiを使っている。新しいAPIである /open/v3/user-chats/{userChatId}/messages を使うべきだが後述の理由で古い方を使った実装を紹介。

生成されたコードを使って実装する

まずはリクエスト時に使う設定を書いていきます。 生成されたコードと同ディレクトリに config.ts を置きます。 ここでポイントとなるのは、fetchApiをisomorphic-unfetchの実装に差し替えているところです。 ブラウザ側から生成されたクライアント側コードをつくう場合は問題ないのですが、今回の場合Node.jsでリクエストをおこなうので必要になります。

import { Configuration } from "./";
import fetch from "isomorphic-unfetch";

const API_ENDPOINT = "https://api.channel.io";
// Name of API KEY is チャットメッセージ
const ACCESS_KEY = "your access key";
const ACCESS_SECRET = "your access secret";

export const config: Configuration = new Configuration({
  fetchApi: fetch,
  basePath: API_ENDPOINT,
  headers: {
    "x-access-key": ACCESS_KEY,
    "x-access-secret": ACCESS_SECRET,
    "Content-Type": "application/json; charset=utf-8",
  },
});

準備ができたので API Route機能を使って実装していきます。

特定のユーザに紐づく新しいチャットを作成

まず、pages/api/channeltalk/user-chats/[userChatId]/index.ts を作成。 パスに [userChatId] のような文字列を含めるとコード内でから const { query: { userChatId } } = req; とするとリクエスト時の文字列を取得できます。

UserChatApiインスタンスを作り、呼び出してあげるだけでOKです。

import { NextApiRequest, NextApiResponse } from "next";
import { UserChatApi, config } from "../../../../../oapi";

export default async function userHandler(
  req: NextApiRequest,
  res: NextApiResponse
): Promise<void> {
  const {
    query: { id },
    method,
  } = req;
  switch (method) {
    case "POST":
      const userChatApi = new UserChatApi(config);
      try {
        const result = await userChatApi.userChats1Raw({
          userId: id as string,
        });
        const value = await result.value();
        const newUserChatId = value.userChat?.id;
        if (newUserChatId == null) {
          throw Error("userChat id is null");
        }
        res.status(200).json({ userChatId: newUserChatId });
      } catch (e) {
        console.error(e);
        res.status(500).end(e);
      }
      break;
    default:
      res.setHeader("Allow", ["POST"]);
      res.status(405).end(`Method ${method} Not Allowed`);
  }
}

型もばっちりついていて良い感じです。

作成したチャットにメッセージを送信

前述の通り、新しいAPIである /open/v3/user-chats/{userChatId}/messages を使いたかったのですが、生成されるコードの型に一部不備があったので説明します。

/open/v3/user-chats/{userChatId}/messages へのリクエストを型情報通りに実装すると下記になります。

// 型
type createMessage2 = (requestParameters: CreateMessage2Request) => Promise<MessageView>

export interface CreateMessage2Request {
    userChatId: string;
    message: Message;
    botName?: string;
}

export interface Message {
    message?: string;
    // 一部の型を文面の都合上省略している
}

// 実装
const userChatApi = new UserChatApi(config);
  await userChatApi.createMessage2({
    userChatId,
    botName: BOT_NAME,
    message: {
      message: `{
      "blocks":[
          {
              "type": "text",
              "value": ${message}
          }
        ]
      }`,
    },
  });

すると Request BodyがAPIが求めている形と異なったものになってしまいます。

// 実際に発行されるRequest Body
{
  message: {
    blocks: [
      {
        type: "text",
        value: "メッセージ",
      },
    ],
  },
};

// APIが求めているRequest Body
{
    blocks: [
      {
        type: "text",
        value: "メッセージ",
      },
    ],
};

生成されたコードを使わずにAPI呼び出しを書くことも考えたのですが、型情報は生成されたものを使いたかったので、 /open/user_chats/{userChatId}/messages を使用することにしました。 /open/user_chats/{userChatId}/messages へのリクエストは先程と同様に型情報どおりに実装するだけでOKなので楽です。

// pages/api/channeltalk/user-chats/[userChatId]/messages.ts
export default async function userChatsHandler(
  req: NextApiRequest,
  res: NextApiResponse
): Promise<void> {
  const {
    query: { userChatId },
    body: { message },
    method,
  } = req;
  switch (method) {
    case "POST":
      try {
        const legacyOpenApi = new LegacyOpenApiApi(config);
        await legacyOpenApi.createMessage5Raw({
          userChatId: userChatId as string,
          message: { message },
          botName: BOT_NAME,
        });
        res.status(200).json({ messageCreated: true });
      } catch (e) {
        console.error(e);
        res.status(500).end(e);
      }
      break;
    default:
      res.setHeader("Allow", ["POST"]);
      res.status(405).end(`Method ${method} Not Allowed`);
  }
}

実装したAPIを使ってメッセージを送る

実装したAPIを順番に呼び、最後に window.ChannelIO("openChat", userChatId); でチャットを開いてあげると完了です。 実際はエラー処理も入ります。

const createNewMessage = async (userId) => {
  const response = await fetch(`/api/channeltalk/users/${userId}/user-chats`, {
    method: "POST",
  });
  const result = await response.json();
  await fetch(`/api/channeltalk/user-chats/${result.userChatId}/messages`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json; charset=utf-8",
    },
    body: JSON.stringify({ message: "メッセージ" }),
  });
  window.ChannelIO("openChat", result.userChatId);
};

We Are Hiring

vivitではTypeScriptでアプリケーション開発をしていきたいフロントエンドエンジニアを絶賛採用中です。

www.wantedly.com