LoginSignup
2
1

More than 1 year has passed since last update.

Twilio Flex機能追加してみた

TwilioのFlexでコールセンターを運用して、実際に追加した機能の一部を書きたいと思います。

Studio、Function、FlexなどTwilioの機能を色々使用しておりますが、
各機能の導入などは公式ドキュメントや、他の方の記事をご参照ください。

説明中の内容は、実際に実装したものを該当部分だけに簡素化している部分や、見せられない箇所をマスクしたり、新たにそこだけ抜き出して作成したりしておりますので、記事中に出てくるソース丸々では動作しない可能性があります。
エラー処理等も省略しています。
また、規模によっては問題が出てくる可能性があります。ご了承ください。

環境

  • Twilio Flex UI(^1.27.0)
  • React (^16.13.1)
  • Twilio Studio
  • Twilio Functions
  • Firebase Firestore
  • Twilio Sync

どういう機能を追加したの?

今回説明する機能は、実際に運用中に問題として上がった部分の解決策としての機能です。

その問題とは

Flexで電話かかってくる時に、急にかかってくるので電話を取るまでに時間がかかったり
慌ててヘッドセットを装着したり等、対応までに時間のロスが生じてしまう

この問題に対応するために以下の機能を追加しました。

電話がかかってきて、コールセンター担当者(エージェント)に電話が回るまでに、電話が来ていることを先に担当者に通知する
そして、誰に電話が回るかも表示する

機能詳細

もう少し詳細に書きます

  1. 電話がかかってきたら、何かしらアクションを起こして、Flex画面に通知をする
  2. 通知には電話が最初に回ってくる人と、次に回ってくる人を表示する
  3. 通知する内容を以下の条件で表示方法を変える
  • 電話が自分に回ってくる場合
  • 電話が自分には回ってこない場合
  • 電話を応対できる人が居ない場合

ではこれを追加していきたいと思います。

完成した画面は最後にあります。

フロー

まずはTwilio Studioでフローの追加を行います。

機能追加前のフロー

現状、電話がかかって来た後の流れとしてはこうなってました。
studioflow_before.png
※掲載する為にフローを簡略化しております。

  1. 電話がかかってくる
  2. Studio Flowに入る
  3. 音声案内が入る(※自動メッセージ)
  4. Flexで待機している担当者に電話が回る(慌てて出る

今回の場合、3.で自動音声を流す時間があり、実際に電話がかかってきてから、担当者に電話が回るまで10秒ほど時間がありました。

この時間に担当者に電話がかかってきたことを通知したいと思います。

機能追加後のフロー

studioflow.png

  1. 電話がかかってくる
  2. Studio Flowに入る
  3. 電話がかかって来たことを通知する(担当者は電話に出る準備をする)
  4. 音声案内が入る(※自動メッセージ)
  5. Flexで待機している担当者に電話が回る(慌てず出る

さて実装に入りましょう…と思ったら問題が

この問題は僕が完全にTwilioを理解しきれてないだけという側面もあると思いますので、
スマートな解決方法があるかもしれません。むしろ教えてください。。:cry:

先ほど上げた機能詳細のうちの2.の部分

通知には電話が最初に回ってくる人と、次に回ってくる人を表示する

この部分を実装するにあたり、どの順番で電話を回すかという情報が必要になってきます。

しかし、上記のStudioフローにある「Send To Flex」というアクション
sendtoflex.png

これを使用するとTask Routerというイイ感じのやつが、イイ感じにしてくれるんですが
(誰に電話を回すかという優先順位の判定を自動でやってくれる)
そこで困ったことが起こりました。


そう、僕にはイイ感じにやってくれるルールがわからん!
サポートに聞くとルールを公開はしてないらしい…ので誰に電話が回るかが、なんとなくしかわからんっていう問題に…

Task Routerが誰に電話回すのか把握できない問題


どういう判定をしているのか自分なりに調べてみましたが
Available状態でオンライン時間が長い人から回ってくるというだけではなく
直近の回ってきた電話の順番(転送とかしたらややこしや~)…みたいな感じで難しそうだったので、ここを解析するのは諦めました。

ただ、オフライン状態になるとこの判定はリセットされるということだけは確定でわかりました。

それを用いて、これをどう解決したか…

電話に出た時用のステータスを追加

Flexでは担当者のステータスがあり
大きく分けるとAvailable(電話出れるよ)と、オフライン(電話出れないよ)の2種類あります。

つまりオフラインにさえなってしまえば、時間はリセットされるので

待ち受け中(Available)
:arrow_down:
電話回ってきて対応(オフライン)
:arrow_down:
電話終わって再度待ち受け中(Available)

1回の電話ごとに、Availableとオフラインのステータスを切り替えると、
単純にAvailable時間の長い人に電話が回りそう!

発想の転換!:sunglasses:
他にいい方法ないかな。。

というわけで、電話に出ている時用のステータス「通話中」を追加します。
Task RouterのActivityから追加します。
※Availabilityを「false(電話出れないよ)」にします

Activities.png

これでTask Routerが誰に電話回すのか把握できない問題は解決するはず。

通話中にステータス変更

さて、問題も解決したところで実装に入ります。

まず担当者が電話を取った時に取ったユーザーのステータスを「通話中」に変えます。

Functionsで作成します。が、単純なAPIではなく
TaskRouterでステータスに変化があったタイミングで呼ばれるように設定します。

Task RouterのEvent CallBackの
箇所で追加するAPIを設定しましょう。
TasRouterEvent.png

では、TaskRouterのイベントを捕捉するFunctionを作成しましょう。

電話を取った時、つまりタスクに担当者が設定された時です。
**「reservation.accepted」**のイベントの時のみ行います。


exports.handler = async (context, event, callback) => {
  const { EventType, WorkspaceSid, TaskQueueName, TaskSid, TaskAttributes, ResourceSid } = event;

  switch (EventType) {
    case "reservation.accepted": {
      const taskAttributes = JSON.parse(TaskAttributes);
      if (!taskAttributes.called) {
        break;
      }
      const client = twilio(apiKey, apiSecret, { accountSid: accountSid });

      // ステータス変更
      await UpdateWorkerStatusTalking(
          client, WorkspaceSid, TaskSid, ResourceSid, workerStatus.Talking.Sid
      );
      break;
    }
  }
  let response = new Twilio.Response();
  response.appendHeader("Content-Type", "text/xml");
  response.setStatusCode(204);
  return callback(null, response);

コールされた時にイベントによって、色々とパラメータが設定されてeventの中に色々は入っています。
まずその中の「TaskAttributes」を見ます。こちらJSON形式で入ってますのでパースします。
calledというプロパティがあり、電話の場合にこれが設定されています。これが入ってる時だけステータスを変更しましょう
※通話だけにしないとチャットの場合なども通話中になってしまいます:cry:

####ステータス更新部分


const UpdateWorkerStatusTalking = async (
  client, workSpaceSid, taskSid, reservationSid, StatusSid
) => {
  // タスクに割り当てられたワーカーを取得
  const reservation = await client.taskrouter
    .workspaces(workSpaceSid)
    .tasks(taskSid)
    .reservations(reservationSid)
    .fetch();

  // ワーカーのステータスを通話中に変更する
  const worker = await client.taskrouter
    .workspaces(workSpaceSid)
    .workers(reservation.workerSid)
    .update({
      activitySid: StatusSid,
    });
};

まずはタスクに割り当てられている担当者を取得します。
そしてその担当者のステータスを更新します。

通話を終わるとAvailableに戻す

このままではずっと通話中になってしまうので、電話が終わってwrapupしてタスクを完了した時に元のステータスに戻します。

タスクを完了した時のイベントと、タスクがキャンセルされた時のイベントで処理を行います。
※自分から電話を発信した場合、電話を掛けた時点でタスクが作成されるが、相手に電話がつながる前に切った場合はキャンセルになるため
「reservation.completed」
「reservation.canceled」

  case "reservation.completed": {
   const taskAttributes = JSON.parse(TaskAttributes);
    if (!taskAttributes.called) {
      break;
    }
    // ステータスを戻す
    await UpdateWorkerStatusAvailable(
      client, WorkspaceSid, TaskSid, ResourceSid, workerStatus.Available.Sid
    );
    break;
  }

注意しなければならない点として、タスクが完了したら戻すだけと思いがちですが、
タスクを複数こなしている可能性もあるので(かかって来た電話をwrapup中に、こちらからかけなおして別タスクを対応中等)
まず他に割り当てられているタスクがあるかを調べて、その割り当てられているタスクのステータスが
「pending」「accepted」「wrapping」のものがあれば、ステータスを更新しないようにします。
そして、上記の条件がクリアな場合はステータスを「Available」に戻しましょう。
全てのタスクが完了になって初めてAvailableに戻るイメージです。

ステータス戻す部分


const UpdateWorkerStatusAvailable = async (
  client, workSpaceSid, taskSid, reservationSid, StatusSid
) => {
    const reservation = await client.taskrouter
    .workspaces(workSpaceSid)
    .tasks(taskSid)
    .reservations(reservationSid)
    .fetch();

  const reservations = await client.taskrouter
    .workspaces(workSpaceSid)
    .workers(reservation.workerSid)
    .reservations.list();

  const targetReservations = reservations.filter((workerReserve) => {
    return (
      workerReserve.reservationStatus === "pending" ||
      workerReserve.reservationStatus === "accepted" ||
      workerReserve.reservationStatus === "wrapping"
    );
  });

  if (targetReservations.length === 0) {
    const worker = await client.taskrouter
      .workspaces(workSpaceSid)
      .workers(reservation.workerSid)
      .update({
        activitySid: StatusSid,
      });
  }
};

これでステータスの切り替えは完了です。

ここまでで、電話を取ってタスクを完了すると、Available時間がリセットされるという準備が出来ました。

これでTaskRouterが次に回す担当者を単なるAvailable時間だけの判定と同じに持って行くことが出来ました!(?)

検知と通知

次にこの部分、電話がかかってきたことをFlexの画面に出すには、
Flex側からアクションを起こして電話がかかってきているか確認するのは無理があるので
逆にFlex側に教えてくれるものが必要です。
そこでリアルタイムで検知が出来る機能を使用します。

候補になったのが、Twilio SyncFirebase FireStoreです。
どちらもドキュメントにデータの追加・更新・削除があった場合に同期(リアルタイムで検知)ができます。

どっちを選択したか結論からいうと、今回はFirebase FireStoreの方を利用しました。

最初Twilio Sync(TwilioSyncClient)を使用していたのですが、動きが不安定というかエラーが多発しました。
サポートに問い合わせてみたんですが、Flex UI内で使用しているTwilioSyncClientと、こっち側で別でTwilioSyncClientを併用するのはオススメしませんとのことでした。
ちょっとこの辺は私もあまり調べたわけではないので(こんなとこで詰まってられなかった)ので

ここは一旦Twilio Syncは置いとこうとFireStoreに決めました(使い慣れてたのもありますが…)

ちなみに、実装当時はTwilioのウェブコンソール上ではSyncに設定している値を見る術がなかったのですが
現在、新コンソールになってからはDocument、List、Map等のどれも画面上から見れるようになっていてわかりやすくなってました:smile:

では実際に導入していきます。

FireStoreにデータを追加

Twilio FunctionにAPIを追加します。
一番最初にStudioフローに出てきたこいつです。

flowapi.png

ここでは、通知をする内容をFireStoreに書き込むために、かかってきた電話の情報をパラメータからゲットします。
そして電話が回るであろう以下のユーザーを取得して書き込みます。

  • 最初に電話が回る人
  • その次に電話が回る人

まずタスクキューに所属しているAvailableな全担当者を取得します。
そして、Available時間が長い人順に並び替えます。

ここでの注意点は、そのまま↑の状態で画面に通知を出してしまうと、
途中にもう1件別の電話があった場合に、2件とも同じ人が表示されてしまいます。

1件目の通知はいちばんAvailable時間が長い人
2件目の通知は2番目に長い人

を出したいので、FireStoreに現在書き込まれている件数から今通知が何件出ているか判断します。
そして、電話が回る人を特定できれば、FireStoreに書き込みます。

exports.handler = async (context, event, callback) => {
  // Studioフローから貰うパラメータ
  const { from, to, callSid } = event;

  const data = { 
    From: from,
    To: to,
    CallSid: callSid,
    First: null,
    Second: null,
  };
  const client = twilio(apiKey, apiSecret, { accountSid: accountSid });

  // Everyoneタスクキューに所属しているAvailableな全ワーカーを取得
  const workers = await client.taskrouter
    .workspaces(workSpace.Sid)
    .workers.list({ available: true, taskQueueName: "Everyone" });
 
  // firestoreから通知データを取得する
  const collectionRef = firestore.collection("advanceNotice");
  const snapShot = await collectionRef.get();
  const index = snapShot.size;

  // 待機時間が長い人で並び替え
  const longWaitDescWorkers =
    workers.length > 0 ? workers.sort((a, b) => {
      return (new Date(a.dateStatusChanged) - new Date(b.dateStatusChanged));
    }) : null;

  // 電話が回る人を設定
  if (longWaitDescWorkers != null) {
    if (longWaitDescWorkers.length > index) {
      // 1人目
      const firstAgent = longWaitDescWorkers[index];
      const firstAttribute = JSON.parse(firstAgent.attributes);
      data.First = firstAttribute.name
      // 2人目
      if (longWaitDescWorkers.length > index + 1) {
        const secondAgent = longWaitDescWorkers[index + 1];
        const secondAttribute = JSON.parse(secondAgent.attributes);
        data.Second = secondAttribute.name
      }
    }
  }

  // firestoreに書き込み
  const docRef = firestore.collection("advanceNotice").doc(callSid);
  await docRef.set(data);

  response.setStatusCode(200);
  response.setBody({ status: 200, data: { key: callSid, data } });
  return callback(null, response);
};

StudioフローでAPI呼び出し

この追加したFunctionを呼ぶようにStudioの設定をします。
貰う予定にしていたパラメータをここで設定します。

studio_notice3.png

これで電話がかかってきたら、FireStoreに誰に電話が回るかの設定がされます。

FireStoreからデータを削除

先ほど追加したデータを削除します。
削除しないと通知がず~~っと画面に出たままになっちゃいます。

削除するタイミングですが、先ほども使用したTaskRouterでのイベントで行います。

タスクが作成された=Flexで担当者に電話が鳴った
となるのでそこで削除処理を行います。

こちらもcalledプロパティを見て、かかってきた電話の場合かどうかを判定します。

「task.created」

  case "task.created": {
    const taskAttributes = JSON.parse(TaskAttributes);
    if (taskAttributes.called === phoneNumber) {
      await DeleteNotice(taskAttributes.call_sid);
    }
    break;
  }

削除処理自体は簡単です。

const DeleteNotice = async (callSid) => {
  await firestore.collection("advanceNotice").doc(callSid).delete();
};

FlexUIでの通知

ここにきてやっとFlex UIの改修です。
準備は整ったので、見た目の部分を整備していきます!
※Flex UIの画面構成とかどんなコンポーネントがあるかとかは公式のドキュメントを参照ください。

通知を出す場所をどこにするかですが、どの画面でも固定で表示したいので
今回はサイドメニューに追加しました。

※後でわかったんですが、サイドメニューだと画面の横幅が狭い場合メニューが格納されてしまいその場合に表示されませんでした…:sweat_smile:ヘッダーとかの方がいいかも

export default class MyFlexPlugin extends FlexPlugin {
  constructor() {
    super(PLUGIN_NAME);
  }

  init(flex, manager) {
    // firebase用Context追加
    RootContainer(flex, manager);
    // 通知追加
    AdvanceNoticeView(flex, manager);
  }
}
export const AdvanceNoticeView = (flex, manager) => {
  flex.SideNav.Content.add(<AdvanceNotice key="advanceNotice" />, {
    sortOrder: -1,
  });

FireStoreにアクセスする関係上、firebase authでの認証(とりあえず匿名認証)も行いたいので
ログイン情報を画面に持たせたいと思います。
その情報はreactのcontextとして持ち回りたいので、FlexUIのRootContainerを一旦クリアして
自分のcontextでRootContainerの中身を覆います

export const RootContainer = (flex, manager) => {
  flex.RootContainer.Content.remove("header");
  flex.RootContainer.Content.remove("container");

  flex.RootContainer.Content.add(
    <FirebaseProvider key="FirebaseProvider">
      <MainHeader />
      <MainContainer />
    </FirebaseProvider>
  );
};

UIフレームワークはMaterial UI(MUI)を使用しています。
MUIには通知にぴったりなSnackbarというコンポーネントがあるので、こちらを使います。
今回はそんなにいっぺんにかかってくることは無い想定ですので、同時に出る通知は3件にしてます。

export default withTheme((props) => {
  const classes = useStyles();
  return (
    <SnackbarProvider
      maxSnack={3}
      classes={{ containerRoot: classes.snackbar, variantSuccess: classes.snackbarColor }}
    >
      <Snackbar />
    </SnackbarProvider>
  );
});

そしてFireStoreを使う準備をして、先ほどのドキュメント更新を受け取る部分を作ります。
FireStoreでのデータ検知を行う部分と、通知のコンポーネントを実装します。

データ検知があれば、取得したデータを整形してsnackbarを追加する処理を行います。

※汚い拙いソースですみません^^;
当時Hooksをあまり理解できていなかったのでuseState使ってますが、useReducer使ってください。。

そして最後に通知音をならして完成です!

const Snackbar = () => {
  const { currentUser } = useContext(FirebaseContext);
  const [noticeList, setNoticeList] = useState({ items: [], longWaitWorker: null });
  const [snackbarKeys, setSnackbarKeys] = useState([]);
  const { enqueueSnackbar, closeSnackbar } = useSnackbar();
  const classes = useStyles();
  const worker = getWorker();

  useEffect(() => {
    if (currentUser) {
      getAdvanceNotice();
    }
  }, [currentUser]);

  // データのリアルタイム検知
  const getAdvanceNotice = async () => {
    const query = firestore.collection("advanceNotice");
    const unsubscribe = query.onSnapshot(async (snapshot) => {
      const noticeList = snapshot.docs.map((doc) => {
        const notice = doc.data();
        notice.key = doc.id;
        return notice;
      });
      const data = { items: noticeList };
      setNoticeList(data);
    });
    return unsubscribe;
  };

  useEffect(() => {
    // snackbarのクローズ
    snackbarKeys.forEach((key) => {
      const hasSnackbar = noticeList.items.some((call) => call.key == key);
      if (!hasSnackbar) {
        closeSnackbar(key);
      }
    });

    // snackbarを表示
    const keys = noticeList.items.map((call) => {
      const hasSnackbar = snackbarKeys.some((key) => key == call.key);
      const key = call.key;
      if (!hasSnackbar) {
        // Snackbarに追加
        enqueueSnackbar(
          <Notice from={call.From} first={call.First} second={call.Second} />,
          {
            key: call.key,
            persist: true,
            variant: call.First ? call.First === worker.attributes.name ? "success" : "info" : "warning",
            anchorOrigin: { vertical: "bottom", horizontal: "left" },
          }
        );

        // 通知音を鳴らす
        let noticeAudio = new Audio(noticeSound);
        noticeAudio.play();
      }
      return key;
    });

    // snackbarkeys設定
    setSnackbarKeys(keys);
  }, [noticeList.items]);

  return <></>;
}

const Notice = (props) => {
  const { from, first, second } = props;
  return (
    <div>
      <div>{formatPhoneNumberForJapan(from)} から 着信中です</div>
      <div>
        {first ? ( 
          second ? (
            <>
              <div className={classes.nextAgent}>( 次候補:{second} )</div>
              <span>対応予定:{first}</span>
            </>
          ) : (
              <div>対応予定:{first}</div>
          )
        ) : (
          <>Availableな担当者がいません</>
        )}
      </div>
    </div>
  );
};

完成

長かったですが、ようやく完成です!
これで電話がかかってきたら以下のパターンで異なる通知が出ます。

  • かかってきた電話が自分に回ってくる場合
  • 違う担当者に回る場合
  • 回せる担当者が居ない場合
  • 複数に電話がかかってきた場合

まとめ

結構前に対応した話なので、もしかしたら載せ忘れている実装部分があるかもしれません。
そして拙いコードでお目汚しすみません。

改めて見ると、Flex UIというよりFlexの全体的な改修ですね。
やってることはそこまでな感じなのに、実際に書くとかなり長くなってしまいました。
簡単な画面追加とかにしたらよかった…

こんなやり方よりもっといい方法があると思いますので、もしもっといい感じに出来るよってのがあれば教えてください…:relieved:

それでは、ありがとうございました。

2
1
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1