Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Flutter + Firebase FunctionsでZendesk Supportを活用する

こんにちは、モバイルクライアントグループの若宮(id:D_R_1009)です。 今年もスギ・ヒノキ花粉は辛かったです。自室でPCを見ている時間が長いこともあり、特に目が辛かった年でした。

今回はPortoで昨年11~12月ごろに対応したZendeskへのCSツール移行について書いてみたいと思います。

play.google.com

apps.apple.com

Zendesk

Zendeskは多くの企業で採用されているCSツールです。 アプリ内お問い合わせやメールによるお問い合わせ、ヘルプセンターの管理、Google Play Storeとの連携などのさまざまな対応を一箇所で対応することができます。 ※App Storeには対応していません*1

www.zendesk.co.jp

Portoではユーザーからのお問い合わせ、ユーザーへのお知らせの2つをZendeskで対応しています。 お問い合わせはZendesk Supportを、お知らせはZendesk Guideです。 Zendesk Guideと言う名前は見慣れないかもしれませんが、SlackやDiscordなどのヘルプページでも利用されています。

https://porto-book.zendesk.com/hc/ja

Zendesk Guideの利用は簡単なため、今回はZendesk Supportにチケットを作成する仕組みについて紹介します。

ZendeskにFlutterからチケットを作成する

FlutterアプリからZendesk Supportにチケットを作成する場合、以下の2つの手段があります。

  1. Zendesk Pluginを利用して、Flutterアプリから直接Zendesk Supportにリクエストする
  2. Firebase Functionsなどを利用して、Zendesk API経由でZendesk Supportにリクエストする

Zendesk Plugin

pub.dev を確認してみると、ZendeskのMobile SDKをFlutterから利用できるようにしたPluginが見つかります。

pub.dev

Pluginの良し悪しの判断は実装と対応時期によりけりになるため、ここでは議論しません。 Mobile SDKを利用するメリットとしては、Flutterアプリ(Flutterのアプリプロジェクトリポジトリ)で完結することが挙げられます。 モバイルアプリの環境だけで開発とテストが行えることは、特に小さなチームでは大きなメリットです。 コードの影響範囲も小さくなるため、チェックする範囲も比較的小さくなります。

一方で、Pluginを使うとサービス固有のデータ(ユーザ情報など)をZendeskに送信することが難しくなります。 「バックエンドからZendesk Supportに送信して、CSチームに表示したいデータがある」には、このアプローチは不適となります。

少し異なる観点で見ると、アップデートとその普及の問題があります。 お問い合わせフォームの改修のために、ユーザーにアプリのバージョンアップを促すのは心苦しいものです。 しかしお問い合わせフォームに「必須」の項目は、CS対応のために「必須」となるものになりがちです。 Flutterのように早く作って改善を回していくプラットフォームの場合、より重要になる視点かなと個人的に思っています。

Firebase Functions

本題です。 今回PortoではFirebase FunctionsからZendesk APIにリクエストする方法を取ることとしました。

ZendeskにAPIリクエストを送る時にはzendesk developersを確認します。 構成が少々独特なため戸惑うことも多いのですが、今回は「チケットの作成」と「チケットへの画像添付」の2つを実装するので ticketsattachments を主に確認します。

developer.zendesk.com

developer.zendesk.com

Zendesk APIドキュメントの読み方

APIドキュメントを開いたら、まずAPIが対応している要素のテーブルをよく読みます。 ドキュメント内にはレスポンスの一覧はありますが、リクエストのプロパティ一覧はありません。 このため、レスポンスの詳細欄から組み立てていくことになります。

構造化されたリクエストを確認したい場合には、提供されているライブラリのコードを読みにいくこともできます。 GitHub上で動いているコードを確認することができるので、各言語に親しんでいれば把握しやすくなります。 ただし、公式にサポートされているのがRubyのSDKのみとなることに留意してください。

developer.zendesk.com

リクエスト

ドキュメントとコードから型を推測し、curlコマンドなどでリクエストを送り、エラーコードから修正をするサイクルを回していきましょう。 試す場合にはAPIドキュメントに載っているJSONのうち、必要となる項目を絞り込んで少しずつ増やしながら対応することをお勧めします。

3分クッキングではありませんが、試行錯誤の結果作成したリクエスト用のコードが次のものになります。*2 なお、コードはブログ用に改変したものとなります。

export = functions
  .region('asia-northeast1')
  .https.onCall(async (data, context) => {
    const uid = context.auth?.uid;
    if (!uid) {
      throw new functions.https.HttpsError('invalid-argument', 'UIDがありません', data);
    }

    const imageTokens: string[] = [];
    const images = request.images ?? [];

    for (const [index, base64] of images.entries()) {
      const mine = guessImageMime(base64);
      const fileName = `image${index}.${mine.split('/')[1]}`;
      const buffer = Buffer.from(base64, 'base64');

      const res = await gaxios.request<AttachmentResponse>({
        url: `https://*****.zendesk.com/api/v2/uploads.json?filename=${fileName}`,
        method: 'POST',
        headers: {
          'Content-Type': `application/binary`,
          Accept: 'application/json',
          Authorization: `${functions.config().zendesk.auth}`,
        },
        body: buffer,
      });

      if (res.status == 201) {
        imageTokens.push(res.data.upload.token);
      } else {
        throw new functions.https.HttpsError('unavailable', 'failed request', data);
      }
    }

    const userInfo = getUserInfo(uid: uid);
    const ticket = new Ticket(request, userInfo, imageTokens);

    const res = await gaxios.request({
      url: 'https://*****.zendesk.com/api/v2/tickets.json',
      method: 'POST',
      headers: {
        'Content-Type': 'application/json;charset=utf-8',
        Accept: 'application/json',
        Authorization: `${functions.config().zendesk.auth}`,
      },
      data: { ticket: ticket },
    });

    return {};
  });

function guessImageMime(base64: string): string {
  if (base64.charAt(0) == '/') {
    return 'image/jpeg';
  } else if (base64.charAt(0) == 'R') {
    return 'image/gif';
  } else if (base64.charAt(0) == 'i') {
    return 'image/png';
  } else {
    // fallback
    return 'image/jpeg';
  }
}

const images = request.images ?? []; にて取得している images は、アプリ側で画像の File を取得しエンコードしたものになります。 今回はZendesk Support上で確認できれば十分なため、アプリ側で画像取得時に品質を圧縮しています。 このため、比較的小さなファイルサイズを扱うこととなりました。 もしも大きなファイルサイズになる場合には、別の方法をお勧めします。

final images = <String>[];
for (final file in _imageList) {
  final bytes = await file.readAsBytes();
  images.add(base64Encode(bytes));
}

画像をBase64エンコードしてしまうとMIME typeが失われてしまいます。 今回は送信元の処理が限定されており十分なテストも可能だったため、文字列を確認して判定する簡易な手法を採用しました。

stackoverflow.com

より汎用的にする場合には、リクエストJSONにMIME typeを含める方が安全だと思われます。 もしもコードを参考にする場合にはご留意ください。

ドキュメントを読み解き、リクエスト用の型定義は下記のようにします。 なお、送信しているデータはブログ用に改変しています。

export interface TicketRequest {
  // default fields
  subject: string;
  message: string;
  name: string;
  email: string;
  // custom fields
  app_version: string;
  os_version: string;
  os: string;
  // option fields
  images: string[] | undefined;
}

const field = {
  username: *****,
  app_version: *****,
  os_version: *****,
  device_model: *****,
  os: *****,
  is_trial: *****,
} as const;

export class Ticket {
  brand_id: number;
  ticket_form_id: number;

  subject: string;
  comment: Comment;
  requester: Requester;
  custom_fields: CustomField[];

  constructor(json: TicketRequest, userInfo: UserInfo, attachmentsToken: string[]) {
    this.brand_id = *****;
    this.ticket_form_id = *****;

    // ポルトのチケットとわかるようにsubjectにポルトの文字を入れる
    this.subject = `(ポルト) ${json.subject}`;
    this.comment = { body: json.message, uploads: attachmentsToken };
    this.requester = { name: json.name, email: json.email };
    this.custom_fields = [
      // TicketRequest
      { id: field.app_version, value: json.app_version },
      { id: field.os_version, value: json.os_version },
      { id: field.os, value: json.os },
      // UserInfo
      { id: field.username, value: userInfo.uid },
      { id: field.is_trial, value: userInfo.isTrial ? 'true' : 'false' },
    ];
  }
}

interface Comment {
  body: string;
  uploads: string[];
}

interface Requester {
  name: string;
  email: string;
}

interface CustomField {
  id: number;
  value: string;
}

TicketRequest と対応する field はZendesk Supportを確認して取得、もしくは作成してください。 固定のフィールド以外は作成時に型を指定するため、かなり柔軟に設計することができます。

画像をアップロードする場合には、 comment プロパティに uploads として生成されたトークンを指定します。 このトークンは画像をアップロードした際に生成されるもので、 comment プロパティなどに含めて使用されることで消費される仕組みとなります。 サンプルコードではコメントの送信前に画像をアップロードし、生成されたトークンを配列で Ticket クラスに渡すことで対応しています。

終わりに

簡単ではありますが、Zendesk APIを利用してチケットを作成するコードを紹介しました。 実装前は簡単だろうと思っていたのですが、色々と独特な点があり苦労したので、どなたかの助けになればいいなと思っています。

私が所属しているモバイルクライアントグループでは、モバイルクライアントと銘打っていますが、必要な範囲で領域を広げてコードを書いたりチャレンジしたりすることができます。 4月時点ではポジションがオープンにはなっていませんが、もしも興味がある場合には、気軽に下記リンクよりカジュアル面談をご応募ください。

open.talentio.com

また、サーバーサイドエンジニアのポジションがオープンになっています。 ご興味のある方はご覧になっていただけますと幸いです。

open.talentio.com

*1:Integration of iTunes AppStore into Zendesk

*2:HTTPクライアントはgaxiosを利用しています。