BEMAロゴ

エンジニアの
成長を支援する
技術メディア

【サーバーレスで構築】kintoneとLINE連携!Flex Messageを活用した商品カタログBotの実装手順(AWS Lambda/TypeScript)

この記事は「BEMA Lab Advent Calendar 2025Open in new tab」の4日目の記事です。
※本アドベントカレンダーの4日目の投稿となります。

はじめに 

本記事では、kintoneOpen in new tabAWSOpen in new tabLINEOpen in new tabを組み合わせて、商品カタログをLINE上で閲覧できるアプリを紹介します。管理者はkintoneで商品データを登録し、利用者(お客様)はLINEから商品一覧を確認します。構成としてはLINE Botに少し機能を足しただけのシンプルなものですが、LINEという使い慣れたUIで操作できます。身近なサービスを連携させて、実用的なアプリを素早く構築する例として紹介します。また、サーバーレス構成でメンテナンスコストを削減、フロントエンドの開発を最小限に抑えることもできます。

システム構成図

お客様がLINEでメッセージを送信すると、LINEサーバーから送信されるWebhookをLambdaで処理します。
Lambdaではkintoneから商品データを取得し、LINEのFlex Messageを用いて商品カタログを表示します。

前提条件

  • Node.js ver.22
  • LINE Bot SDK ver.10.5.0
  • Express ver.5.1.0
  • TypeScript ver.5.9.3

本記事で利用するサービスは基本的には無料で利用できます。kintoneは検証目的で開発者ライセンスを利用します。

kintoneでつくる管理者側画面

kintoneはサイボウズ社が提供しているローコードツールです。
UI部品をドラッグ・アンド・ドロップして画面を作成して、データを保存できます。ITエンジニア以外でも業務システムを作成できます。

商品データの登録を想定して、以下のような項目を持つ画面を作成します。

項目

UI部品種別

フィールドコード

タイトル

1行テキストボックス

title

商品説明

複数行テキストボックス

description

価格

数値

price

販売開始日

日付

sellStartDate

納品日数

数値

leadTime

フィールドコードは画面内で項目を一意に特定できる名前です。APIリクエストで指定したり、レスポンスに含まれます。

LINEでつくるお客様側画面

お客様はLINEで商品カタログを閲覧できるようにします。

LINEでメッセージを送信すると、サーバー側でkintoneから商品データを取得します。

商品データを基に、画像や段組レイアウトを組み合わせて見やすいレイアウトでLINE上にカタログを表示します。

AWS LambdaでのWebhook処理

利用者がLINEで送信されたメッセージは、LINEサーバーからWebhookで受信して処理します。
今回はNode.js環境のTypeScriptでBotサーバーを作成します。

必要なライブラリをインストール

Webhookを受信するサーバーはExpressを用いて処理します。Lambdaで動作させるため**@codegenie/serverless-express**を利用します。
LINEの送受信は、公式から提供されているSDK(Software Development Kit)**@line/bot-sdk**を用いると便利です。

$ yarn add @line/bot-sdk express @codegenie/serverless-express
$ yarn add -D typescript @types/express @types/aws-lambda

Botサーバーへはどこからでもアクセスされる可能性があるため、LINEの開発ガイドラインに沿って正規のLINEサーバーから送信されたことを検証します。
SDKを使うと簡単に検証できます。

// src/index.ts
import express, { Application } from "express";
import serverlessExpress from "@codegenie/serverless-express";
import { middleware } from "@line/bot-sdk";
import "dotenv/config";
import router from "./router";

const app: Application = express();

const lineConfig = {
  channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN!,
  channelSecret: process.env.LINE_CHANNEL_SECRET!,
};

app.use("/line", middleware(lineConfig)); // LINE署名検証

app.use(router);

export const handler = serverlessExpress({ app });

以下のファイルも作成しておきます。

// src/router.ts
import express from "express";
import LineController from "./controller.js";

const router = express.Router();

router.post("/line/webhook", async (req, res) => {
  const controller = new LineController();
  await controller.webhook(req);
  res.sendStatus(200);
});

export default router;

ここで、AWS Lambdaの新しい関数を作成し、ソースコードをアップロードします。Webhookで呼び出せるように、関数URLも作成しておきます。
また、Lambdaの環境変数として以下の値を設定しておきます。

環境変数

設定値

LINE_CHANNEL_ACCESS_TOKEN

LINEチャネルアクセストークン

LINE_CHANNEL_SECRET

LINEチャネルシークレット

KINTONE_BASE_URL

kintone アプリのURL( 例: https://example.cybozu.com)

KINTONE_API_TOKEN

kintoneAPIトークン

KINTONE_APP_ID

アプリID

ここまででLINEサーバーからWebhookを受信する処理ができました。
LINE Console上で「Webhook URL」に以下のURLを設定して検証ボタンを押下し、通信が成功すれば問題ありません。

https://【AWS Lambdaの関数URL】/line/webhook

kintoneからの商品データ取得

商品データはkintoneからAPIを用いて取得します。
kintoneでは公式APIOpen in new tabを用いることで、データを取得したり、更新したりできます。

今回は条件に合致する複数の商品レコードを取得したいので「複数のレコードを取得する」のAPIを利用します。
エンドポイントは以下のようになります。

https://【サブドメイン】.cybozu.com/k/v1/records.json

認証方式はいくつかありますが、今回は「APIトークン認証」を利用します。
これはアプリおよび権限(レコード読み取り、更新など)ごとに発行できる形式です。発行手順は公式ドキュメントOpen in new tabを参照してください。
APIリクエスト時は次のようにヘッダーに設定します。

const res = await fetch(url.href, {
    headers: {
    "X-Cybozu-API-Token": process.env.KINTONE_API_TOKEN!, // APIトークンを渡す
    },
});

APIリクエスト時はクエリパラメータで検索条件を指定できます。
今回は販売中の商品を取得したいので「販売開始日 <= システム日付 」を条件(query)に指定します。
また、新商品に注目させたいので、販売開始日の順で並び替えます。

const url = new URL(`${process.env.KINTONE_BASE_URL!}/k/v1/records.json`);
    url.searchParams.append("app", process.env.KINTONE_APP_ID!);
    url.searchParams.append(
      "query",
      sellStartDatetime <= TODAY() order by sellStartDatetime desc,  // 取得条件
    );

APIレスポンスは何も指定しないとany型になってしまいます。
kintone-dts-genというツールを利用すると、kintoneアプリの項目名、UIパーツに応じてTypeScriptの型定義ファイル(d.ts)を生成できます。
詳細な手順は公式ドキュメントOpen in new tabを参照してください。
出力結果として、次のような型定義ファイルが生成されます。

// types/kintone-ecapp.d.ts

declare namespace kintoneTypes {
  interface ProductCatalogAppFields {
    description: kintone.fieldTypes.MultiLineText;
    leadTime: kintone.fieldTypes.Number;
    title: kintone.fieldTypes.SingleLineText;
    price: kintone.fieldTypes.Number;
    sellStartDate: kintone.fieldTypes.Date;
  }
  interface SavedProductCatalogAppFields extends ProductCatalogAppFields {
    $id: kintone.fieldTypes.Id;
    // 中略
  }
}

生成したファイルと、kintone自体の型定義ファイルをtsconfig.jsonに追記しておきます。

// tsconfig.json

 "files": [
    "./node_modules/@kintone/dts-gen/kintone.d.ts",
    "./types/kintone-ecapp.d.ts",
  ],

recordsの型としてSavedLINEECAppFieldsを指定します。

/** kintone APIのレスポンス */
type KintoneECAppResponse = {
  records: kintoneTypes.SavedProductCatalogAppFields[];
};

const itemDetails: KintoneECAppResponse = await res.json();

コード全体は次のようになります。


// src/KintoneService.ts

export class KintoneService {
  public async getECProducts(): Promise<Product[]> {
    const url = new URL(`${process.env.KINTONE_BASE_URL!}/k/v1/records.json`);
    url.searchParams.append("app", process.env.KINTONE_APP_ID!);
    url.searchParams.append(
      "query",
      sellStartDate <= TODAY() order by sellStartDate desc
    );

    const res = await fetch(url.href, {
      headers: {
        "X-Cybozu-API-Token": process.env.KINTONE_API_TOKEN!,
      },
    });

    if (!res.ok) {
      throw new Error("kintone API実行(商品取得)に失敗しました");
    }

    const productData: KintoneProductCatalogAppResponse = await res.json();

    return productData.records.map((item) => ({
      title: item.title.value,
      description: item.description.value,
      price: Number(item.price.value),
      leadTime: Number(item.leadTime.value),
    }));
  }
}

/** 商品 */
export interface Product {
  /** タイトル */
  title: string;
  /** 説明 */
  description: string;
  /** 価格 */
  price: number;
  /** 納品日数 */
  leadTime: number;
}

/** kintone APIのレスポンス */
type KintoneProductCatalogAppResponse = {
  records: kintoneTypes.SavedProductCatalogAppFields[];
};

Flex Messgeを用いた商品カタログ表示

kintoneから取得した商品データを用いて、LINEで商品カタログを表示します。LINEではFlex Messageを使うと、テキストだけではなく、画像と組み合わせてレイアウトしたり、複数のメッセージをスワイプして表示したりできます。柔軟なレイアウトを作成できるため、ユーザーへの視覚的な訴求効果が高くなります。

今回は次のようなレイアウトを実装してみます。

 - 水平方向に商品を並べスワイプして閲覧
 - 画像とテキストを1つのメッセージで組み合わせて表示
 - フォントサイズや色を変える

Flex MessageはJSONで定義します。
上から順にコンテナ、ブロック、コンポーネントの3層構造となっています。

     message = { 
        type: "flex",
        altText: "商品カタログ",
        contents: {
          type: "carousel", // 1. コンテナ(カルーセル)
          contents: ecData.map((item) => ({
            type: "bubble",
            hero: { // 2. ブロック(hero block)
              type: "image", // 3. コンポーネント(画像)
              url: "【背景画像URL】",
              size: "full",
              aspectRatio: "20:13",
              aspectMode: "cover",
            },
            body: {  // 2. ブロック(body block)
              type: "box",
              layout: "vertical",
              contents: [
                {
                  type: "text", // 3. コンポーネント(テキスト)
                  text: item.title,
                  weight: "bold",
                  size: "xl",
                },
                // 中略   
              ],
            },
          })),
        },
      };

1. コンテナ
コンテナはメッセージ表示数を定義します。今回は複数表示したいので「カルーセル」を指定します。

2. ブロック
ブロックは1つのメッセージのレイアウトを定義します。
今回は、上から順にhero blockに画像、body blockに商品説明などのテキストを配置します。

3. コンポーネント
コンポーネントは各ブロックに表示するUI部品を定義します。
hero blockには画像を単体で表示します。body blockは「画像」と「テキスト」「ボックス」を表示します。

TypeScriptでは型定義の恩恵を受けられるとはいえ、なにもない状態からJSONでFlex Messageを作成するのは難しい場合があります。
公式で提供されているFlex Message SimulatorOpen in new tabを使うことで、GUIでレイアウトを作成できます。JSONも取得できるので便利です。

ローディング表示

ユーザーがメッセージを送信してから、商品カタログを表示するまでに時間を要する場合があります。
何も応答がないと、何度も送信したり、離脱してしまったりとユーザーの体験が悪くなってしまいます。
これを防ぐため、Webhookでメッセージを受信したらLINEでローディング表示するようにします。

SDKを用いると次のコードで表示できます。ローディングの最大表示秒数をloadingSecondsを指定します。時間が経過しなくても、Botサーバーからメッセージを返信しだい表示は消えます。

// ローディング表示
await this.client.showLoadingAnimation({
    chatId: 【LINE ID】,
    loadingSeconds: 30, // 最大表示秒数
});

コード全体は次のようになります。

// src/LINEService.ts

import { messagingApi } from "@line/bot-sdk";

import { WebhookEvent } from "@line/bot-sdk";
import { Product, KintoneService } from "./KintoneService.js";

const { MessagingApiClient } = messagingApi;

/**
 * LINEサービス
 */
export class LineService {
  /** LINEクライアント */
  private client: messagingApi.MessagingApiClient;
  constructor() {
    this.client = new MessagingApiClient({
      channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN!,
    });
  }

  /**
   * LINEメッセージの処理
   * @param events LINE Webhookイベント
   * @returns
   */
  public async processWebhook(events: WebhookEvent[]): Promise<void> {
    try {
      for (let i = 0; i < events.length; i++) {
        const event = events[i] as WebhookEvent;

        if (!event || (event.type !== "message" && event.type !== "postback")) {
          // LINE Consoleでの動作検証用
          return;
        }

        let message = ""; // ユーザーが送信したメッセージ
        if ("message" in event && event.message.type === "text") {
          message = event.message.text;
        }

        const lineId = event.source.userId; // ユーザーのLINE ID(返信に必要)
        if (!lineId) {
          throw new Error("LINE IDを取得できませんでした");
        }

        // ローディング表示
        await this.client.showLoadingAnimation({
          chatId: lineId,
          loadingSeconds: 10,
        });

        // kintoneからEC商品取得
        const kintoneService = new KintoneService();
        const ecData = await kintoneService.getECProducts();

        // LINEでカタログをFlex Messageで送信
        await this.showECCatalog(event.replyToken, ecData);
      }
    } catch (error) {
      console.error(error);
      throw error;
    }
  }

  /**
   * 商品カタログをFlex Messageで表示する
   * @param replyToken LINEリプライトークン
   * @param ecData 商品データ
   */
  private async showECCatalog(
    replyToken: string,
    ecData: Product[]
  ): Promise<void> {
    let message: messagingApi.Message = {
      type: "text",
      text: "商品がありません",
    };

    if (ecData.length > 0) {
      message = {
        type: "flex",
        altText: "商品カタログ",
        contents: {
          type: "carousel",

         contents: ecData.map((item) => ({
            type: "bubble",
            hero: {
              type: "image",
              url: "【背景画像URL】",
              size: "full",
              aspectRatio: "20:13",
              aspectMode: "cover",
            },
            body: {
              type: "box",
              layout: "vertical",
              contents: [
                {
                  type: "text",
                  text: item.title,
                  weight: "bold",
                  size: "xl",
                  wrap: true,
                },
                {
                  type: "text",
                  text: item.description,
                  size: "sm",
                  wrap: true,
                },
                {
                  type: "box",
                  layout: "vertical",
                  margin: "lg",
                  spacing: "sm",
                  contents: [
                    {
                      type: "box",
                      layout: "baseline",
                      spacing: "sm",
                      contents: [
                        {
                          type: "icon",
                          url: "【価格アイコンURL】",
                          offsetTop: "xs",
                        },
                        {
                          type: "text",
                          text: ${item.price}円,
                          wrap: true,
                          color: "#666666",
                          size: "sm",
                          flex: 5,
                          margin: "sm",
                          offsetStart: "sm",
                        },
                      ],
                      alignItems: "center",
                    },
                    {
                      type: "box",
                      layout: "baseline",
                      spacing: "sm",
                      contents: [
                        {
                          type: "icon",
                          url: "【納品日数アイコンURL】",
                          offsetTop: "xs",
                        },
                        {
                          type: "text",
                          text: ${item.leadTime}日後にお届け,
                          wrap: true,
                          color: "#666666",
                          size: "sm",
                          flex: 5,
                          margin: "sm",
                          offsetStart: "sm",
                        },
                      ],
                      alignItems: "center",
                    },
                  ],
                },
              ],
            },
          })),
        },
      };
    }

    try {
      await this.client.replyMessage({
        replyToken: replyToken,
        messages: [message],
      });
    } catch (error) {
      console.error(error);
      throw error;
    }
  }
}

まとめ

この記事では、kintoneとLINEをAWSのサーバーレスな構成でつなぎ合わせて、商品カタログBotを構築できることを説明しました。
kintoneでの柔軟なアプリ開発とデータ利用、LINEの使い慣れたUIを用いることで複雑なフロントエンド開発しなくてもBotを構築できます。

この記事が役に立ったと思ったら、
ぜひ「いいね」とシェアをお願いします!

リンクをコピーXでシェアするfacebookでシェアする

この記事を書いた人

秋田 大介
秋田 大介
自社サービス開発企業にてシステム開発経験を積み、2023年にメンバーズに中途入社。Windowsソフト開発からWeb系にキャリアチェンジ。現在は、バックエンドを主軸にしながらも、フロントエンドも対応できるエンジニアとして業務している。
詳しく見る
ページトップへ戻る