LINE BOT で引用が出来るようになったので試してみた

2023.09.19

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

はじめに

9/14 に LINE 公式から下記のニュースがリリースされました。

Messaging APIで引用メッセージを送受信できるようになりました

手持ちの LINE ボット環境に対して対応させてみましたので記事にいたします。

引用メッセージを送るには

リンク先のページを読むと、対応自体はそんなに難しいことではなく、Webhook の中に quoteToken というフィールドがあるのでそれを使用して応答のときに同じく quoteToken というフィールドへ設定すれば良さそうです。

以前、記事(下記)にした DynamoDB へ保存する LINE ボットに引用して返す処理を追加するよう試してみました。

対応してみる

対応前の Lambda ソースは下記となります。

Lambda ソース(250 行近いのでたたんでいます)
import { Client, validateSignature, WebhookRequestBody, LINE_SIGNATURE_HTTP_HEADER_NAME } from "@line/bot-sdk";
import { Message } from "@line/bot-sdk/lib/types";
import { APIGatewayProxyResult, APIGatewayProxyEvent, Context } from "aws-lambda";
import axios from "axios";
import { DynamoDBClient }  from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand, PutCommand, DeleteCommand } from "@aws-sdk/lib-dynamodb";

// Secrets Manager から取得するための諸々
const cacheEnabled = process.env.PARAMETERS_SECRETS_EXTENSION_CACHE_ENABLED || "false";
const requestCache: boolean = JSON.parse(cacheEnabled.toLowerCase());
const httpPort = process.env.PARAMETERS_SECRETS_EXTENSION_HTTP_PORT || "2773";
const requestSecretId = process.env.SECRET_ID || "MySecretId";
const requestEndpoint = `http://localhost:${httpPort}/secretsmanager/get?secretId=${requestSecretId}`;
const requestOptions = {
  headers: {
    "X-Aws-Parameters-Secrets-Token": requestCache? process.env.AWS_SESSION_TOKEN: "",
  },
};

// DynamoDB へアクセスするための諸々
const numMaxRecord: number = Number(process.env.TABLE_MAXIMUM_NUMBER_OF_RECORD) || 5;
const stringTableName: string = process.env.TABLE_NAME || "linememo";
const clientDB = DynamoDBDocumentClient.from(new DynamoDBClient({ region: process.env.AWS_REGION }));

// レスポンス結果(200/成功、500/失敗)を固定で設定
const resultError: APIGatewayProxyResult = {
  statusCode: 500,
  body: "Error",
};
const resultOK: APIGatewayProxyResult = {
  statusCode: 200,
  body: "OK",
};

// DynamoDB から引数のユーザに関する最後に登録した通し番号を取得する
async function getUserTableLastCount(stringUser: string): Promise<number> {
  // 逆順で1つだけ取得
  const command = new QueryCommand({
    TableName: stringTableName,
    KeyConditionExpression: "lineuserid = :userid",
    ExpressionAttributeValues: { ":userid": stringUser },
    ScanIndexForward: false,
    Limit: 1,
  });
  try {
    const response = await clientDB.send(command);
    if (response.Count !== undefined) {
      // 一つもなかった場合は Items が空配列なのでゼロを返す
      if (response.Count === 0) {
        return 0;
      }
      // 値がある場合は id に何らかの値が入っているのでそれを返す
      if (response.Items !== undefined && response.Items[0].id !== undefined) {
        console.log("Items id : ", response.Items[0].id);
        return Number(response.Items[0].id);
      }
    }
    return -1;
  } catch (error) {
    console.error("getUserTableLastCount : ", error);
  }
  return -1;
};

// DynamoDB から引数のユーザに関するデータを全件取得する
interface getUserTableProc {
  stringUser: string;
  numCount?: number,
  boolForward?: boolean;
}
async function getUserTable(argProc: getUserTableProc): Promise<Record<string, any>[]|undefined> {
  // クエリーを発行して取得する
  const command = new QueryCommand({
    TableName: stringTableName,
    KeyConditionExpression: "#lineuserid = :userid",
    ExpressionAttributeNames: { "#lineuserid": "lineuserid" },
    ExpressionAttributeValues: { ":userid": argProc.stringUser },
    Limit: argProc.numCount,
    ScanIndexForward: argProc.boolForward,
  });
  try {
    const { Items: items = [] } = await clientDB.send(command);
    console.log(JSON.stringify(items));
    return items;
  } catch (error) {
    console.error("getUserTable : ", error);
    return undefined;
  }
}

export const handler = async (eventLambda: APIGatewayProxyEvent, contextLambda: Context): Promise<APIGatewayProxyResult> => {
  console.log(JSON.stringify(eventLambda));
  // Secrets Manager から値を取得
  const responseSM = await axios.get(requestEndpoint, requestOptions);
  const jsonSecret = JSON.parse(responseSM.data["SecretString"]);
  const clientLine = new Client({
    channelAccessToken: jsonSecret.ACCESS_TOKEN!,
    channelSecret: jsonSecret.CHANNEL_SECRET,
  });

  const stringSignature = eventLambda.headers[LINE_SIGNATURE_HTTP_HEADER_NAME];
  // Line の署名認証
  if (!validateSignature(eventLambda.body!, clientLine.config.channelSecret!, stringSignature!)) {
    // 署名検証がエラーの場合はログを出してエラー終了
    console.log("署名認証エラー", stringSignature!);
    return resultError;
  }
  // 文面の解析
  const bodyRequest: WebhookRequestBody = JSON.parse(eventLambda.body!);
  if (typeof bodyRequest.events[0] === "undefined") {
    // LINE Developer による Webhook の検証は events が空配列の body で来るのでその場合は 200 を返す
    console.log("Webhook inspection");
    return resultOK;
  }
  if (bodyRequest.events[0].type !== "message" || bodyRequest.events[0].message.type !== "text") {
    // text ではない場合は終了する
    console.log("本文がテキストではない", bodyRequest);
    return resultError;
  } else {
    // 要求メッセージを取得
    const requestText: string = bodyRequest.events[0].message.text;
    // 要求元 Line UserID を取得
    const requestUserId: string = bodyRequest.events[0].source.userId || "";
    // 応答メッセージが動的になるので初期化
    var messageReply: Message[] = [];

    try {
      if (requestText.startsWith("regist:")) {
        const stringMemo = requestText.replace("regist:", "");
        // 保存されているデータで id の最大値を取得
        const numLastId: number = await getUserTableLastCount(requestUserId);
        if (numLastId === -1) {
          // 失敗したら登録せず throw して終了
          throw new Error("最大値取得に失敗")
        }
        // PutItem 発行
        await clientDB.send(
          new PutCommand({
            TableName: stringTableName,
            Item: {
              lineuserid: requestUserId,
              id: numLastId + 1,
              RegistTime: new Date().getTime(),
              memo: stringMemo,
            }
          })
        );
        // UserId のデータを全件取得
        const items = await getUserTable({ stringUser: requestUserId, }) || [];
        // 5件以上あるものは削除(基本的には1件)
        items.splice(-numMaxRecord);
        await Promise.all(
          items.map((element) => {
            const commandDelete = new DeleteCommand({
              TableName: stringTableName,
              Key: {
                lineuserid: element["lineuserid"],
                id: element["id"],
              }
            });
            clientDB.send(commandDelete);
          })
        );
        // 応答メッセージを作成
        messageReply.push({
          type: "text",
          text: stringMemo + " の登録が完了しました",
        });
      } else if (requestText.startsWith("list")) {
        // UserId のデータを全件取得
        // 新しい順なので降順で取得する
        const items = await getUserTable({
          stringUser: requestUserId,
          numCount: numMaxRecord,
          boolForward: false,
        }) || [];
        // 応答メッセージへセット
        items.forEach((responseItem: any) => {
          if (responseItem.memo !== undefined) {
            messageReply.push({
              type: "text",
              text: responseItem.id + " : " + responseItem.memo,
            });
          }
        });
        if (messageReply.length === 0) {
          // 一件も無い場合はレスポンスが空なので応答メッセージを別途設定
          messageReply.push({
            type: "text",
            text: "一覧が存在しません",
          });
        }
      } else if (requestText.startsWith("delete:")) {
        const stringIndex = requestText.replace("delete:", "");
        console.log("lineuserid : ", requestUserId, "id : ", stringIndex);
        // DeleteItem 発行
        const responseDelete = await clientDB.send(
          new DeleteCommand({
            TableName: stringTableName,
            Key: {
              lineuserid: requestUserId,
              id: Number(stringIndex),
            }
          })
        );
        console.log(JSON.stringify(responseDelete));
        // 応答メッセージをセット
        messageReply.push({
          type: "text",
          text: stringIndex + " の削除処理が完了しました",
        });
      } else {
        // オウム返しする場合、1個の配列で応答メッセージをセット
        messageReply.push({
          type: "text",
          text: bodyRequest.events[0].message.text,
        });
      }
    } catch (e) {
      // コンソールにエラーを出しておく
      console.error(e);
      var stringReply = "エラーが発生しました";
      if (e instanceof Error) {
        stringReply = e.message;
      }
      // 応答メッセージをセット
      messageReply.push({
        type: "text",
        text: stringReply,
      });
    }
    // 応答メッセージ送信
    await clientLine.replyMessage(
      bodyRequest.events[0].replyToken,
      messageReply
    );
    // OK 返信をセット
    return resultOK;
  }
};

対応としては 109 行目でbodyRequestを取得した以降、フィールドに設定されたquoteTokenを取得し、応答メッセージのquoteTokenに設定すればよいのですが…

エラーが出ます

このまま、まともにquoteTokenを追加実装しようとすると、こんな感じのエラーが出ます。

ソースを開いている VSCode のスクショ

quoteTokenメンバーが無いエラーなのですが、@line/bot-sdk の最新ライブラリをアップデートしてソースを参照するもフィールドの定義は無く同様のエラーが出たのでこのライブラリの GitHub の Issue を眺めていると、

Quote Message #461

と、もう既に Issue が立ち上がっており「アップデートされるまで待っています」というリプライが付いていました。

ということで

TypeScript の長所を消してしまうのですが、型定義を使わず実装します。

  • 取得する時のWebhookRequestBody型を外す
  • 送るときのmessageReplyany型の配列にしておく

対応した Lambda ソースは下記です。

修正箇所をハイライトして同じくたたんでいます。
import { Client, validateSignature,/* WebhookRequestBody,*/ LINE_SIGNATURE_HTTP_HEADER_NAME } from "@line/bot-sdk";
// import { Message } from "@line/bot-sdk/lib/types";
import { APIGatewayProxyResult, APIGatewayProxyEvent, Context, } from "aws-lambda";
import axios from "axios";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand, PutCommand, DeleteCommand, } from "@aws-sdk/lib-dynamodb";

// Secrets Manager から取得するための諸々
const cacheEnabled = process.env.PARAMETERS_SECRETS_EXTENSION_CACHE_ENABLED || "false";
const requestCache: boolean = JSON.parse(cacheEnabled.toLowerCase());
const httpPort = process.env.PARAMETERS_SECRETS_EXTENSION_HTTP_PORT || "2773";
const requestSecretId = process.env.SECRET_ID || "MySecretId";
const requestEndpoint = `http://localhost:${httpPort}/secretsmanager/get?secretId=${requestSecretId}`;
const requestOptions = {
  headers: {
    "X-Aws-Parameters-Secrets-Token": requestCache ? process.env.AWS_SESSION_TOKEN : "",
  },
};

// DynamoDB へアクセスするための諸々
const numMaxRecord: number = Number(process.env.TABLE_MAXIMUM_NUMBER_OF_RECORD) || 5;
const stringTableName: string = process.env.TABLE_NAME || "linememo";
const clientDB = DynamoDBDocumentClient.from( new DynamoDBClient({ region: process.env.AWS_REGION }));

// レスポンス結果(200/成功、500/失敗)を固定で設定
const resultError: APIGatewayProxyResult = {
  statusCode: 500,
  body: "Error",
};
const resultOK: APIGatewayProxyResult = {
  statusCode: 200,
  body: "OK",
};

// DynamoDB から引数のユーザに関する最後に登録した通し番号を取得する
async function getUserTableLastCount(stringUser: string): Promise<number> {
  // 逆順で1つだけ取得
  const command = new QueryCommand({
    TableName: stringTableName,
    KeyConditionExpression: "lineuserid = :userid",
    ExpressionAttributeValues: { ":userid": stringUser },
    ScanIndexForward: false,
    Limit: 1,
  });
  try {
    const response = await clientDB.send(command);
    if (response.Count !== undefined) {
      // 一つもなかった場合は Items が空配列なのでゼロを返す
      if (response.Count === 0) {
        return 0;
      }
      // 値がある場合は id に何らかの値が入っているのでそれを返す
      if (response.Items !== undefined && response.Items[0].id !== undefined) {
        console.log("Items id : ", response.Items[0].id);
        return Number(response.Items[0].id);
      }
    }
    return -1;
  } catch (error) {
    console.error("getUserTableLastCount : ", error);
  }
  return -1;
}

// DynamoDB から引数のユーザに関するデータを全件取得する
interface getUserTableProc {
  stringUser: string;
  numCount?: number;
  boolForward?: boolean;
}
async function getUserTable( argProc: getUserTableProc): Promise<Record<string, any>[] | undefined> {
  // クエリーを発行して取得する
  const command = new QueryCommand({
    TableName: stringTableName,
    KeyConditionExpression: "#lineuserid = :userid",
    ExpressionAttributeNames: { "#lineuserid": "lineuserid" },
    ExpressionAttributeValues: { ":userid": argProc.stringUser },
    Limit: argProc.numCount,
    ScanIndexForward: argProc.boolForward,
  });
  try {
    const { Items: items = [] } = await clientDB.send(command);
    console.log(JSON.stringify(items));
    return items;
  } catch (error) {
    console.error("getUserTable : ", error);
    return undefined;
  }
}

export const handler = async ( eventLambda: APIGatewayProxyEvent, contextLambda: Context): Promise<APIGatewayProxyResult> => {
  console.log(JSON.stringify(eventLambda));
  // Secrets Manager から値を取得
  const responseSM = await axios.get(requestEndpoint, requestOptions);
  const jsonSecret = JSON.parse(responseSM.data["SecretString"]);
  const clientLine = new Client({
    channelAccessToken: jsonSecret.ACCESS_TOKEN!,
    channelSecret: jsonSecret.CHANNEL_SECRET,
  });

  const stringSignature = eventLambda.headers[LINE_SIGNATURE_HTTP_HEADER_NAME];
  // Line の署名認証
  if (!validateSignature( eventLambda.body!, clientLine.config.channelSecret!, stringSignature!)) {
    // 署名検証がエラーの場合はログを出してエラー終了
    console.log("署名認証エラー", stringSignature!);
    return resultError;
  }
  // 文面の解析
  const bodyRequest = JSON.parse(eventLambda.body!);
  if (typeof bodyRequest.events[0] === "undefined") {
    // LINE Developer による Webhook の検証は events が空配列の body で来るのでその場合は 200 を返す
    console.log("Webhook inspection");
    return resultOK;
  }
  if (bodyRequest.events[0].type !== "message" || bodyRequest.events[0].message.type !== "text") {
    // text ではない場合は終了する
    console.log("本文がテキストではない", bodyRequest);
    return resultError;
  } else {
    // 要求メッセージを取得
    const requestText: string = bodyRequest.events[0].message.text;
    // 要求元 Line UserID を取得
    const requestUserId: string = bodyRequest.events[0].source.userId || "";
    // 応答メッセージが動的になるので初期化
    var messageReply: Array<any> = [];
    // 引用トークンを取得
    const quoteToken = bodyRequest.events[0].message.quoteToken;

    try {
      if (requestText.startsWith("regist:")) {
        const stringMemo = requestText.replace("regist:", "");
        // 保存されているデータで id の最大値を取得
        const numLastId: number = await getUserTableLastCount(requestUserId);
        if (numLastId === -1) {
          // 失敗したら登録せず throw して終了
          throw new Error("最大値取得に失敗");
        }
        // PutItem 発行
        await clientDB.send(
          new PutCommand({
            TableName: stringTableName,
            Item: {
              lineuserid: requestUserId,
              id: numLastId + 1,
              RegistTime: new Date().getTime(),
              memo: stringMemo,
            },
          })
        );
        // UserId のデータを全件取得
        const items = (await getUserTable({
          stringUser: requestUserId
        })) || [];
        // 5件以上あるものは削除(基本的には1件)
        items.splice(-numMaxRecord);
        await Promise.all(
          items.map((element) => {
            const commandDelete = new DeleteCommand({
              TableName: stringTableName,
              Key: {
                lineuserid: element["lineuserid"],
                id: element["id"],
              },
            });
            clientDB.send(commandDelete);
          })
        );
        // 応答メッセージを作成
        messageReply.push({
          type: "text",
          text: stringMemo + " の登録が完了しました",
          quoteToken: quoteToken,
        });
      } else if (requestText.startsWith("list")) {
        // UserId のデータを全件取得
        // 新しい順なので降順で取得する
        const items = (await getUserTable({
          stringUser: requestUserId,
          numCount: numMaxRecord,
          boolForward: false,
        })) || [];
        // 応答メッセージへセット
        items.forEach((responseItem: any) => {
          if (responseItem.memo !== undefined) {
            messageReply.push({
              type: "text",
              text: responseItem.id + " : " + responseItem.memo,
              quoteToken: quoteToken,
            });
          }
        });
        if (messageReply.length === 0) {
          // 一件も無い場合はレスポンスが空なので応答メッセージを別途設定
          messageReply.push({
            type: "text",
            text: "一覧が存在しません",
            quoteToken: quoteToken,
          });
        }
      } else if (requestText.startsWith("delete:")) {
        const stringIndex = requestText.replace("delete:", "");
        console.log("lineuserid : ", requestUserId, "id : ", stringIndex);
        // DeleteItem 発行
        const responseDelete = await clientDB.send(
          new DeleteCommand({
            TableName: stringTableName,
            Key: {
              lineuserid: requestUserId,
              id: Number(stringIndex),
            },
          })
        );
        console.log(JSON.stringify(responseDelete));
        // 応答メッセージをセット
        messageReply.push({
          type: "text",
          text: stringIndex + " の削除処理が完了しました",
          quoteToken: quoteToken,
        });
      } else {
        // オウム返しする場合、1個の配列で応答メッセージをセット
        messageReply.push({
          type: "text",
          text: bodyRequest.events[0].message.text,
          quoteToken: quoteToken,
        });
      }
    } catch (e) {
      // コンソールにエラーを出しておく
      console.error(e);
      var stringReply = "エラーが発生しました";
      if (e instanceof Error) {
        stringReply = e.message;
      }
      // 応答メッセージをセット
      messageReply.push({
        type: "text",
        text: stringReply,
        quoteToken: quoteToken,
      });
    }
    // 応答メッセージ送信
    await clientLine.replyMessage(
      bodyRequest.events[0].replyToken,
      messageReply
    );
    // OK 返信をセット
    return resultOK;
  }
};

動作確認

動かしてみたイメージはこんな感じです。

動かしてみたスクショ

うまく引用がされていますね。

おわりに

作成していた LINE ボットにユーザからのメッセージを引用する仕組みを実装してみました。

ライブラリが仕様にリアルタイムで追従していただきたいところではありますが、気長にアップデートを待ちたいと思います。

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

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。
「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。
現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。
少しでもご興味あれば、アノテーション株式会社WEBサイト をご覧ください。