LINE公式アカウントを使った勤怠管理アプリを構築した。出退勤の打刻、体調チェック、アルコール検査、業務記録の入力までをLINEのトーク画面で完結させる仕組みだ。
今回は、Messaging APIのWebhook処理とLIFF(LINE Front-end Framework)を組み合わせた構成について、コードを交えながらまとめる。
技術スタック
| 技術 | 用途 |
|---|---|
| Next.js (App Router) | アプリ本体 |
| LINE Messaging API | Webhook受信・メッセージ返信 |
| LIFF | LINE内ブラウザでフォーム表示 |
| @line/bot-sdk | Messaging API クライアント |
| Neon (PostgreSQL) | データベース |
| Drizzle ORM | DB操作 |
| Vercel | ホスティング・Cron |
全体アーキテクチャ
ユーザーとシステムのやり取りは2つの経路がある。
1. Webhook(Messaging API)
ユーザーがリッチメニューをタップすると、LINEプラットフォームからWebhookが飛ぶ。出退勤・体調チェック・アルコール検査はすべてこの経路で処理される。
2. LIFF(LINE内ブラウザ)
業務記録の入力はフォームが必要なので、LIFFで専用ページを開く。LIFFからユーザーのプロフィールを取得し、APIへPOSTする。
この2つを組み合わせることで、シンプルな操作はBot側で即応答し、複雑な入力はWebアプリに切り替えるという使い分けができる。
Webhookの署名検証
LINEからのWebhookリクエストが正規のものか検証する必要がある。x-line-signatureヘッダーに含まれる署名を、チャネルシークレットで検証する。
import crypto from "crypto";
export function verifySignature(body: string, signature: string): boolean {
const hash = crypto
.createHmac("SHA256", process.env.LINE_CHANNEL_SECRET!)
.update(body)
.digest("base64");
return hash === signature;
}
@line/bot-sdkにもvalidateSignatureが用意されているが、App RouterのRoute Handlersで使う場合、リクエストボディをreq.text()で取得してから検証する流れになるため、自前で実装した方が扱いやすかった。
署名検証を突破したら、イベントをパースして処理する。
export async function POST(req: NextRequest): Promise<NextResponse> {
const body = await req.text();
const signature = req.headers.get("x-line-signature");
if (!signature || !verifySignature(body, signature)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const { events } = JSON.parse(body);
await Promise.all(events.map((event) => handleEvent(event)));
return NextResponse.json({ status: "ok" });
}
eventsは配列で届くため、Promise.allで並列処理する。LINEは200を返さないとリトライしてくるので、Webhookは速やかにレスポンスを返すことが重要だ。
イベントのルーティング
LINEから届くイベントは主に3種類。
| イベント | 発火タイミング |
|---|---|
| follow | ユーザーが公式アカウントを友だち追加した時 |
| message | ユーザーがテキストを送信した時 |
| postback | リッチメニューやボタンをタップした時 |
postbackのデータはURLSearchParams形式で送られてくるので、actionパラメータでどの処理を呼ぶか判定する。
case "postback": {
const data = new URLSearchParams(event.postback.data);
const action = data.get("action");
switch (action) {
case "clock_in":
await handleClockIn(event.replyToken, userId);
break;
case "clock_out":
await handleClockOut(event.replyToken, userId);
break;
case "health_check": {
const status = data.get("status") ?? "";
await handleHealthCheck(event.replyToken, userId, status);
break;
}
case "alcohol_check": {
const result = data.get("result") ?? "";
await handleAlcoholCheck(event.replyToken, userId, result);
break;
}
}
break;
}
action=health_check&status=good のようにデータを構造化しておくと、1つのpostbackハンドラで複数のアクションを分岐できるので便利だ。
出勤フローの設計
出勤は「ボタン1つで即打刻」ではなく、体調確認とアルコール検査を経てから打刻される。フローは以下の通り。
出勤ボタン → 体調チェック(良い/悪い) → アルコール検査(OK/NG) → 出勤完了
体調が「悪い」場合は、テキスト入力で詳細を記録してからアルコール検査に進む。
このフローをWebhookだけで実現するために、DBレコードの状態で「今どのステップにいるか」を判定している。
export async function handleClockIn(
replyToken: string,
lineUserId: string
): Promise<void> {
const user = await findUser(lineUserId);
if (!user) {
await replyText(replyToken, "ユーザー登録が完了していません。");
return;
}
const existing = await findTodayAttendance(user.id);
if (existing?.clockIn) {
await replyText(replyToken, "本日は出退勤済みです。");
return;
}
if (!existing) {
await db.insert(attendances).values({ userId: user.id, date: today });
}
await replyWithFlexButtons(
replyToken,
"おはようございます!\n体調はいかがですか?",
HEALTH_BUTTONS
);
}
Flex Messageでボタンを表示し、タップするとpostbackが飛ぶ。次のステップはhealthStatusやalcoholCheckのカラムがnullかどうかで判定する。セッションを持たなくてもDBの状態だけで会話フローを管理できる。
リッチメニューの状態管理
リッチメニューを3種類用意し、ユーザーの勤怠状態に応じて切り替える。
| メニュー | 状態 |
|---|---|
| A: 出勤メニュー | 未出勤(デフォルト) |
| B: 退勤メニュー | 出勤中 |
| C: グレーアウト | 退勤済み |
切り替えはlinkRichMenuIdToUserで行う。
export async function switchToClockOutMenu(userId: string): Promise<void> {
await lineClient.linkRichMenuIdToUser(userId, RICHMENU_CLOCK_OUT_ID);
}
export async function switchToDisabledMenu(userId: string): Promise<void> {
await lineClient.linkRichMenuIdToUser(userId, RICHMENU_DISABLED_ID);
}
出勤したらBに切り替え、退勤したらCに切り替え、翌朝のCronジョブで全員Aに戻す。richMenuBatchを使うと一括でリセットできる。
export async function resetAllUsersMenu(): Promise<void> {
await lineClient.richMenuBatch({
operations: [
{ type: "link", from: RICHMENU_CLOCK_OUT_ID, to: RICHMENU_DEFAULT_ID },
{ type: "link", from: RICHMENU_DISABLED_ID, to: RICHMENU_DEFAULT_ID },
],
});
}
ユーザー単位でリッチメニューが切り替わるため、出退勤済みのユーザーが誤って再打刻するのを視覚的に防げる。
LIFFで業務記録フォームを実装する
業務記録は訪問先・時刻・走行距離など入力項目が多いため、LIFFでWebページを開いて入力する。
LIFF初期化時にユーザー認証を行い、userIdを取得する。
import liff from "@line/liff";
export async function getLiffProfile(): Promise<{ userId: string; displayName: string }> {
await liff.init({ liffId: process.env.NEXT_PUBLIC_LIFF_ID! });
if (!liff.isLoggedIn()) {
liff.login();
return new Promise(() => {});
}
return await liff.getProfile();
}
未ログイン時はliff.login()でLINEログインにリダイレクトする。return new Promise(() => {})でコンポーネントのレンダリングを止めている点がポイントだ。リダイレクト後に再度この関数が呼ばれ、今度はisLoggedIn()がtrueになる。
フォーム送信後はAPIにlineUserIdを含めてPOSTし、liff.closeWindow()でLINE内ブラウザを閉じる。
const handleClose = () => {
if (liff.isInClient()) {
liff.closeWindow();
}
};
liff.isInClient()でLINE内ブラウザかどうかを判定し、外部ブラウザで開かれた場合はウィンドウを閉じないようにしている。
Summary
LINE Messaging APIのWebhookで打刻や検査のフローを処理し、LIFFでリッチなフォーム入力を補完する構成は、ユーザーがLINEから離れずに業務を完結できるのが利点だ。
リッチメニューの状態管理とDBのレコード状態を組み合わせることで、セッション管理なしでも会話的なフローを実現できる。「Webhookは速やかにレスポンスを返す」「署名検証を必ず行う」「リッチメニューでユーザーの誤操作を防ぐ」あたりは、LINE Bot開発で共通して押さえておくべきポイントだと思う。
