firebase functionsでline worksのBotを作る。

に公開

firebase functionsでline works のBotを作ったので手順を公開します。
サーバーレスで様々なAPIと接続できるので、シンプルに作るのであれば結構良いかと思います。

firebase functionsとは

googleが提供しているBaas(Backend as a service)です。
サーバーを扱わないので比較的安定して作動させることができます。
https://firebase.google.com/?hl=ja

line works とは

Lineが提供するグループウェアです。
グループウェアとしてはとても安く、UIもほぼLineと同じなのでITに疎い方でも
安心して使用することができます。(とても大事)
https://line-works.com/

構成図

ここで重要なことはCallbackまでがBotで、Messageを送信するのはApplicationの役割ということです。


Line worksより

流れは
firebaseのプロジェクトの作成→Line Worksのアカウントを登録し、BotとApplicationを登録する→コーディング
という流れです。

firebase プロジェクトの作成

こちらを参考にして作りました。firebase-toolsはインストールしておいた方が良いとおもいます。

https://zenn.dev/msy/articles/856e9b5855c45c

Line Worksのアカウントの作成とBotの登録

アプリの登録(トークルームにメッセージを送信する際に使う)
https://qiita.com/iwaohig/items/d62f5bc7cd83ae8fa89b

Applicationのスコープを設定してください。
bot bot.message bot.readが必要です。
Service Account認証もしておきます。
private keyをダウンロードしておきましょう。

Botの登録(トークルームからメッセージを受信するときに使う)
https://qiita.com/iwaohig/items/e995f020ac6e35222bba

Callback URLは最後に設定するので、最初はなしで設定してください。

コーディング

Line Worksの認証方法ではJWT認証を使用するみたいです。
この辺がちょっと面倒なので認証部のみコードを準備しました。

auth.ts
auth.ts

import * as jwt from "jsonwebtoken";
import * as logger from "firebase-functions/logger";
import axios from "axios";

// 認証関連の定数
export const SERVICE_ACCOUNT = "YOUR_SERVICE_ACCOUNT";
export const SERVICE_ACCOUNT_PRIVATE_KEY = `YOUR_ACCOUNT_PRIVATE_KEY`;

// OAuth2クライアント情報
export const CLIENT_ID = "CLIENT_ID";
export const CLIENT_SECRET = "CLIENT_SECRET";

/**
 * JWT認証トークンを取得する関数
 * @return {Promise<string>} JWTトークン
 */
export async function getJwtToken(): Promise<string> {
  const now = Math.floor(Date.now() / 1000);

  // LINE Works APIの公式仕様に準拠したJWTペイロード
  const payload = {
    iss: CLIENT_ID, // 発行者:クライアントID
    sub: SERVICE_ACCOUNT, // サブジェクト:サービスアカウント
    aud: "https://auth.worksmobile.com/oauth2/v2.0/token", // 対象者:トークンエンドポイント
    iat: now, // 発行時刻
    exp: now + 3600, // 有効期限(1時間)
  };

  // 秘密鍵のフォーマットとペイロード詳細を確認
  logger.info("JWT signing details", {
    hasPrivateKey: !!SERVICE_ACCOUNT_PRIVATE_KEY,
    keyStart: SERVICE_ACCOUNT_PRIVATE_KEY.substring(0, 30),
    keyEnd: SERVICE_ACCOUNT_PRIVATE_KEY.substring(SERVICE_ACCOUNT_PRIVATE_KEY.length - 30),
    payloadIss: payload.iss,
    payloadSub: payload.sub,
    payloadAud: payload.aud,
    payloadIat: payload.iat,
    payloadExp: payload.exp,
    currentTime: now,
    timeToExpiry: payload.exp - now,
  });

  try {
    const token = jwt.sign(payload, SERVICE_ACCOUNT_PRIVATE_KEY, {
      algorithm: "RS256",
      keyid: CLIENT_ID, // キーID:クライアントID
    });

    logger.info("JWT token generated successfully", {
      tokenLength: token.length,
      tokenHeader: token.split(".")[0],
    });

    return token;
  } catch (error) {
    logger.error("Failed to sign JWT token", {
      error: error instanceof Error ? error.message : String(error),
      payloadDetails: payload,
    });
    throw error;
  }
}

/**
 * アクセストークンを取得する関数
 * @return {Promise<string>} アクセストークン
 */
export async function getAccessToken(): Promise<string> {
  try {
    const jwtToken = await getJwtToken();

    // JWTトークンの内容をログ出力(デバッグ用)
    logger.info("Generated JWT token", {
      jwtToken: jwtToken.substring(0, 50) + "...",
      serviceAccount: SERVICE_ACCOUNT,
    });

    const params = new URLSearchParams();
    params.append("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer");
    params.append("assertion", jwtToken);
    params.append("client_id", CLIENT_ID);
    params.append("client_secret", CLIENT_SECRET);
    params.append("scope", "bot"); // Botのスコープ

    logger.info("Sending OAuth2 request", {
      url: "https://auth.worksmobile.com/oauth2/v2.0/token",
      clientId: CLIENT_ID,
      hasClientSecret: !!CLIENT_SECRET,
      serviceAccount: SERVICE_ACCOUNT,
      grantType: "urn:ietf:params:oauth:grant-type:jwt-bearer",
      scope: "bot",
    });

    const response = await axios.post(
      "https://auth.worksmobile.com/oauth2/v2.0/token",
      params.toString(),
      {
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    );

    logger.info("OAuth2 response received", {
      status: response.status,
      hasAccessToken: !!response.data.access_token,
      tokenType: response.data.token_type,
      expiresIn: response.data.expires_in,
    });

    return response.data.access_token;
  } catch (error) {
    // より詳細なエラー情報をログ出力
    if (error instanceof Error && "response" in error) {
      const axiosError = error as { response?: { status?: number; statusText?: string; data?: unknown }; config?: { url?: string; method?: string; headers?: unknown } };
      logger.error("OAuth2 authentication failed", {
        error: error.message,
        status: axiosError.response?.status,
        statusText: axiosError.response?.statusText,
        responseData: axiosError.response?.data,
        requestConfig: {
          url: axiosError.config?.url,
          method: axiosError.config?.method,
          headers: axiosError.config?.headers,
        },
        // OAuth2設定情報(セキュリティのため一部マスク)
        authConfig: {
          clientId: CLIENT_ID,
          hasClientSecret: !!CLIENT_SECRET,
          serviceAccount: SERVICE_ACCOUNT,
        },
      });
    } else {
      logger.error("Failed to get access token", {
        error: error instanceof Error ? error.message : String(error),
      });
    }
    throw error;
  }
}


sendMessage.ts

YOURBOTIDは書き換えてください。

sendMessage.ts
/**
 * LINE Works APIにメッセージを送信する関数
 * @param {string} accessToken アクセストークン
 * @param {string} userId 送信先ユーザーID
 * @param {BotMessage} message 送信するメッセージ
 * @return {Promise<void>}
 */
async function sendMessage(accessToken: string, userId: string, message: BotMessage): Promise<void> {
  try {
    const apiUrl = "https://www.worksapis.com/v1.0/bots/"+YOURBOTID+"/users/" + userId + "/messages";

    const response = await axios.post(apiUrl, message, {
      headers: {
        "Authorization": `Bearer ${accessToken}`,
        "Content-Type": "application/json",
      },
    });

    logger.info("✅ Message sent successfully", {
      status: response.status,
      userId: userId,
      messageType: message.content.type,
    });
  } catch (error) {
    if (error instanceof Error && "response" in error) {
      const axiosError = error as { response?: { status?: number; statusText?: string; data?: unknown }; config?: { url?: string; method?: string } };
      logger.error("❌ Failed to send message", {
        error: error.message,
        status: axiosError.response?.status,
        statusText: axiosError.response?.statusText,
        responseData: axiosError.response?.data,
        userId: userId,
        messageType: message.content.type,
      });
    } else {
      logger.error("❌ Failed to send message", {
        error: error instanceof Error ? error.message : String(error),
        userId: userId,
      });
    }
    throw error;
  }
}

こちらをonRequest内で使用します。

index.ts
// アクセストークンを取得
          const accessToken = await getAccessToken();

          // メッセージを送信
          await sendMessage(accessToken, userId, message);

最後にデプロイします。

firebase deploy --only functions

APIのURLが発行されるのでそちらをBotに登録してください。

以上です。お疲れさまでした。

Discussion