友だち登録とブロック解除を区別出来るようになったので試してみた

LINEヤフー社から友だち追加かブロック解除かを見分けられるようになったとリリースされましたので自身のLINEボット環境に取り入れてみました。途中、少し困ったことも合わせて書いています。
2024.02.28

こんにちは、高崎@アノテーションです。

はじめに

LINEヤフー社より先日、下記のニュースがリリースされました。

Messaging APIにおいて、友だち追加とブロック解除がWebhookのフォローイベントで判別できるようになりました

以前の LINE ボットの Webhook イベントでは、友だち追加とブロック解除は同じfollowイベントで区別が出来なかったのですが、これが判別出来るようになったようです。

早速、自身の LINE ボット環境に組み込んでみました。

今回追加された機構

リンク先の Messaging API の内容について抜粋して記載しますと、下記のハイライトした内容が追加となります。

リンク先の 1=を抜粋

{
  "destination": "xxxxxxxxxx",
  "events": [
    {
      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
      "type": "follow",
      "mode": "active",
      "timestamp": 1462629479859,
      "source": {
        "type": "user",
        "userId": "U4af4980629..."
      },
      "webhookEventId": "01FZ74A0TDDPYRVKNK77XKC3ZR",
      "deliveryContext": {
        "isRedelivery": false
      },
      "follow": {
        "isUnblocked": true // ユーザーがLINE公式アカウントをブロック解除した
      }
    }
  ]
}

"type": "follow"イベントにfollow.isUnblockedというフラグが追加され、ここでユーザが新たに友だち追加されたのかブロックを解除したのかの判別が可能となります。

ブロックと解除の操作について

友だち追加の操作については不要かと思いますが、ブロックとその解除の操作を少し紹介します。

ブロック

トーク画面にてハンバーガーメニューをタップ

「ブロック」をタップ

この操作を行うことで、トーク画面へ移行すると「ブロック中」である旨の表示になります。

ブロック解除

「ブロック中」である旨の画面にて「ブロック解除」をタップ

解除すると元のトーク画面になりメッセージ送信が可能となります。

LINEボット環境に入れてみた

環境について

いつものごとく、下記のブログにて追加した環境を使用します。

実装方針

対象ソースはsrc/lambda/use-case/line-bot-use-case/use-case.ts

今まではmessageイベントのtextタイプのみを対象として処理していましたが、ここにfollowイベントのfollow.isUnblockedを判断する文を追加して、

  • 友だち追加ならいらっしゃいませ!
  • ブロック解除ならおかえりなさい!

というボットを返すようにしたいと思います。

ソース

ソースは長くなるのでたたみました。※変更した箇所をハイライトしています。

src/lambda/use-case/line-bot-use-case/use-case.ts

import { WebhookRequestBody, WebhookEvent } from "@line/bot-sdk";
import { FollowEvent } from "@line/bot-sdk/dist/webhook/api";
import { LineBot } from "../../domain/support/line-bot/line-bot";
import { MemoStoreRepository } from "../../domain/model/memoStore/memoStore-repository";
import { ImageCraftRepository } from "../../domain/model/imageCraft/imageCraft-repository";
import { execRegisterCommand, execListCommand, execDeleteCommand, execAskCommand, ReplyMessages } from "./dispatchCommand";

export class InvalidSignatureError extends Error {}
export class InvalidRequestError extends Error {}
export class UnexpectedError extends Error {}
export type LineBotUseCaseResult = 
  | void
  | InvalidSignatureError
  | InvalidRequestError
  | UnexpectedError;
export type ExecWebhookEventResult =
  | void
  | UnexpectedError;

/**
 * event ディスパッチ処理
 * @param webhookEvent : event インスタンス
 * @param lineBotClient : LINE の Messaging API 等実行インスタンス
 * @param memoStoreRepository : memoStore データリポジトリ
 * @param imageCraftRepository : imageCraft データリポジトリ
 * @returns ExecWebhookEventResult 戻り値(エラーインスタンス、エラー無しの場合は undefined)
 */
const dispatchEvent = async({
  webhookEvent,
  lineBotClient,
  memoStoreRepository,
  imageCraftRepository,
}: {
  webhookEvent: WebhookEvent,
  lineBotClient: LineBot,
  memoStoreRepository: MemoStoreRepository,
  imageCraftRepository: ImageCraftRepository,
}): Promise<ExecWebhookEventResult> => {
  console.log("LINE Bot use case start.", webhookEvent);

  try {
    const commandResult: ReplyMessages = [];
    if (webhookEvent.type === "follow") {
      if (webhookEvent.follow.isUnblocked === true) {
        commandResult.push({
          type: "text",
          text: "おかえりなさいませ!",
        });
      } else {
        commandResult.push({
          type: "text",
          text: "いらっしゃいませ!",
        });
      }
    } else if (webhookEvent.type === "message" && webhookEvent.message.type === "text") {
      const requestText: string = webhookEvent.message.text;
      const lineUserId: string = webhookEvent.source.userId || "";
      const quoteToken = webhookEvent.message.quoteToken;
      if (requestText.startsWith("regist:")) {
        // データ登録機能
        const resultRegisterCommand = await execRegisterCommand({
          memoStoreRepository,
          lineUserId,
          memoText: requestText.replace("regist:", ""),
          quoteToken,
        });
        console.log("result : ", resultRegisterCommand);
        resultRegisterCommand.map((item) => commandResult.push(item));
      } else if (requestText.startsWith("list")) {
        // データ一覧表示機能
        const resultListCommand = await execListCommand({
          memoStoreRepository,
          lineUserId,
          quoteToken,
          maxListNumber: Number(process.env.TABLE_MAXIMUM_NUMBER_OF_RECORD) || 5,
        });
        console.log("result : ", resultListCommand);
        resultListCommand.map((item) => commandResult.push(item));
      } else if (requestText.startsWith("delete:")) {
        // データ削除機能
        const resultDeleteCommand = await execDeleteCommand({
          memoStoreRepository,
          lineUserId,
          messageId: Number(requestText.replace("delete:", "")),
          quoteToken,
        });
        console.log("result : ", resultDeleteCommand);
        resultDeleteCommand.map((item) => commandResult.push(item));
      } else if (requestText.startsWith("ask:")) {
        // 生成 AI へ画像生成依頼機能
        const resultAskCommand = await execAskCommand({
          imageCraftRepository,
          orderedText: requestText.replace("ask:", ""),
          quoteToken,
        });
        console.log("result : ", resultAskCommand);
        resultAskCommand.map((item) => commandResult.push(item));
      } else {
        // オウム返し
        commandResult.push({
          type: "text",
          text: webhookEvent.message.text,
          quoteToken: quoteToken,
        });
        // クリップボードアクションを使ったテンプレートを送信
        commandResult.push({
          type: "template",
          altText: "文字をオウム返しします",
          template: {
            type: "buttons",
            title: "オウム返しボットテスト",
            text: "オウム返しテキストをクリップボードへコピーします",
            actions: [{
              type: "clipboard",
              label: "コピー",
              clipboardText: webhookEvent.message.text,
            },],
          }
        });
      }
    } else {
      console.error("メッセージが受け付けられない形式");
      return new UnexpectedError();
    }
    console.log("commandResult : ", commandResult);
    // LINE リプライ実行
    await lineBotClient.replyMessage({
      replyToken: webhookEvent.replyToken,
      messages: commandResult,
    });
  } catch (e) {
    console.error(e);
    return new UnexpectedError();
  }
  return undefined;
}

export type LineBotUseCase = (
  stringBody?: string,
  stringSignature?: string,
) => Promise<LineBotUseCaseResult>;
/**
 * LINE ボットのユースケース実行処理
 * @param lineBotClient : LINE の Messaging API 等実行インスタンス
 * @param validateSignature : 署名検証用関数
 * @param memoStoreRepository : memoStore データリポジトリ
 * @param imageCraftRepository : imageCraft データリポジトリ
 * @returns LineBotUseCase 戻り値(エラーインスタンス、エラー無しの場合は undefined)
 */
export const execLineBotUseCase = ({
  lineBotClient,
  memoStoreRepository,
  imageCraftRepository,
}: {
  lineBotClient: LineBot,
  memoStoreRepository: MemoStoreRepository,
  imageCraftRepository: ImageCraftRepository,
}): LineBotUseCase => 
async (stringBody?: string, stringSignature?: string): Promise<LineBotUseCaseResult> => {
  if (stringBody == null) {
    return new InvalidRequestError();
  }
  if (stringSignature == null) {
    return new InvalidRequestError();
  }
  // 署名検証
  const validateResult = lineBotClient.checkSignature( stringBody, stringSignature );
  if (!validateResult) {
    return new InvalidSignatureError();
  }
  // body から必要なパラメータを取得
  const bodyRequest: WebhookRequestBody = JSON.parse(stringBody!);
  const { events } = bodyRequest;
  // event 配列ごとにディスパッチを呼ぶ(通常は 1 つしか無い)
  const results = await Promise.allSettled(
    events.map(async (webhookEvent) => {
      await dispatchEvent({
        webhookEvent,
        lineBotClient,
        memoStoreRepository,
        imageCraftRepository,
      });
    })
  );
  console.log("event results : ", results);
  // events 配列ごとに処理した結果を確認
  const errorResults = (results as PromiseFulfilledResult<ExecWebhookEventResult>[])
    .filter((result) => result.value instanceof Error);
  // 一つでもエラーがあればエラー終了
  if (errorResults.length > 0) {
    console.error("処理がエラーになりました", errorResults);
    return new UnexpectedError();
  }
  // エラーがない場合は undefined で終了
  return undefined;
}

が…

ソースをビルドしたのですが…

(´・ω・`)

エラー追跡

なぜエラーが出たのかWebhookEventを見てみましょう。

@line/bot-sdk/dist/types.d.ts

/**
 * JSON objects which contain events generated on the LINE Platform.
 *
 * @see [Webhook event objects](https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects)
 */
export type WebhookEvent = MessageEvent | UnsendEvent | FollowEvent | UnfollowEvent | JoinEvent | LeaveEvent | MemberJoinEvent | MemberLeaveEvent | PostbackEvent | VideoPlayCompleteEvent | BeaconEvent | AccountLinkEvent | DeviceLinkEvent | DeviceUnlinkEvent | LINEThingsScenarioExecutionEvent | DeliveryEvent;

@line/bot-sdk/dist/types.d.ts

/**
 * Event object for when your account is added as a friend (or unblocked).
 */
export type FollowEvent = {
    type: "follow";
} & ReplyableEvent;

ReplyableEventはどうなっているかと言うと…

@line/bot-sdk/dist/types.d.ts

export type ReplyableEvent = EventBase & {
    replyToken: string;
};

EventBase

@line/bot-sdk/dist/types.d.ts

export type EventBase = {
    /**
     * Channel state.
     *
     * `active`: The channel is active. You can send a reply message or push message from the bot server that received this webhook event.
     *
     * `standby`: The channel is waiting. The bot server that received this webhook event shouldn't send any messages.
     */
    mode: "active" | "standby";
    /**
     * Time of the event in milliseconds
     */
    timestamp: number;
    /**
     * Source user, group, or room object with information about the source of the event.
     */
    source: EventSource;
    /**
     * Webhook Event ID, an ID that uniquely identifies a webhook event
     */
    webhookEventId: string;
    /**
     * Whether the webhook event is a redelivered one or not
     */
    deliveryContext: DeliveryContext;
};

…とまぁ、探したのですがfollowで定義されたtype、classは見つからずでした。

follow.isUnblocked を探す旅

さて、定義しているところは無いのかというと…。

@line/bot-sdk/dist/webhook/model/followEvent.d.ts

/**
 * Webhook Type Definition
 * Webhook event definition of the LINE Messaging API
 *
 * The version of the OpenAPI document: 1.0.0
 *
 *
 * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
 * https://openapi-generator.tech
 * Do not edit the class manually.
 */
import { FollowDetail } from "./followDetail";
/**
 * Event object for when your LINE Official Account is added as a friend (or unblocked). You can reply to follow events.
 */
import { EventBase } from "./models";
export type FollowEvent = EventBase & {
    type: "follow";
    /**
     * Reply token used to send reply message to this event
     */
    replyToken: string;
    /**
     */
    follow: FollowDetail;
};
export declare namespace FollowEvent { }

ここにfollow定義を発見。
FollowDetailはというと…

@line/bot-sdk/dist/webhook/model/followDetail.d.ts

/**
 * Webhook Type Definition
 * Webhook event definition of the LINE Messaging API
 *
 * The version of the OpenAPI document: 1.0.0
 *
 *
 * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
 * https://openapi-generator.tech
 * Do not edit the class manually.
 */
export type FollowDetail = {
    /**
     * Whether a user has added your LINE Official Account as a friend or unblocked.
     */
    isUnblocked: boolean;
};

居ました。

対処

2024/02/28 現在 bot-sdk の GitHub Issue にも記事は上がっていないため、対処は非公式だとは思いますが下記のようにしようと思います。

  • FollowEvent@line/bot-sdk/dist/webhook/apiから参照したものを import する
  • followイベントの時にWebhookEventの変数を前項で import したFollowEventにキャストして使用する

改めて、実装

下記のようにしました。
※無理やりキャストしているのでfollowEvent.followの null チェックを念の為に入れています。

src/lambda/use-case/line-bot-use-case/use-case.tsの修正箇所

import { FollowEvent } from "@line/bot-sdk/dist/webhook/api";
     :
    if (webhookEvent.type === "follow") {
      const followEvent: FollowEvent = (webhookEvent as FollowEvent);
      if (followEvent.follow != null) {
        if (followEvent.follow.isUnblocked === true) {
          commandResult.push({
            type: "text",
            text: "おかえりなさいませ!",
          });
        } else {
          commandResult.push({
            type: "text",
            text: "いらっしゃいませ!",
          });
        }
      }
    } else if (webhookEvent.type === "message" && webhookEvent.message.type === "text") {
    :

動作確認

ビルドやデプロイについては以前から記事にて行っていますので省略します。

ブロック解除

ブロック→ブロック解除したときの動作結果です。

友だち追加

友だち追加を新規で行ったときの動作結果です。

表示の使い分けが出来ていますね。

おわりに

今回は新たに追加された、友だち追加かブロック解除かをボットの Webhook にて判断出来る機構について試してみました。

サンプルはデフォルト固定のあいさつメッセージが入っていたので余計な返信が返っていますが、使い分けが出来るようになっていました。

今回のような応答メッセージ以外にも友だち追加の時に自動でユーザ登録を行う機構を入れたい時に、ブロック解除ではない時に仮登録を行う、といった判断ができると思います。

@line/sdk-bot ライブラリにこの機構が入っていなかったのが残念でしたが、アップデートを気長に待ちたいと思います。

今回の変更した環境は下記になります。

アノテーション株式会社について

アノテーション株式会社はクラスメソッドグループのオペレーション専門特化企業です。
サポート・運用・開発保守・情シス・バックオフィスの専門チームが、最新 IT テクノロジー、高い技術力、蓄積されたノウハウをフル活用し、お客様の課題解決を行っています。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、アノテーション株式会社 採用サイトをぜひご覧ください。