プロジェクト 実装ガイド
🔴 未設定
00

概要・アーキテクチャ

ミリーちゃんLINE BotのシステムアーキテクチャとTechStackの全体像

📱
LINEユーザー
💬
LINE Platform
Messaging API
⚙️
Webhook Server
Node.js / Python
🗄️
Database
ユーザー状態管理
CRON Job
深夜配信スケジューラ
🤖
Scenario Engine
フェーズ管理・分岐
🟢
Node.js + Express
推奨。@line/bot-sdkが充実していて実装が速い
🐍
Python + FastAPI
line-bot-sdk-python対応。AIとの連携も◎
🚀
Railway / Render
無料枠あり。HTTPS必須のWebhookに対応
🗄️
Supabase / PlanetScale
ユーザーステータスのDB。無料プランで十分
01

LINE Developers 設定

Messaging APIチャネルの作成とトークン取得

  1. 1
    LINE Developers Console にアクセス
    LINEアカウントでログインし、プロバイダーを作成
  2. 2
    新しいチャネルを作成
    チャネルの種類:Messaging API を選択
    チャネル名:ミリーちゃん / カテゴリ:エンターテインメント
  3. 3
    各種トークンを取得・保存
    「チャネル基本設定」と「Messaging API設定」タブから以下を取得:
    CHANNEL_SECRET チャネル基本設定 → チャネルシークレット
    CHANNEL_ACCESS_TOKEN Messaging API設定 → チャネルアクセストークン(長期)
  4. 4
    Webhook URLを設定
    サーバーデプロイ後に設定(Step 2完了後)
    Webhook URL形式
    https://your-server.railway.app/webhook
  5. 5
    応答メッセージをOFFにする
    Messaging API設定 →「応答メッセージ」をオフ、「Webhook」をオン
02

サーバー構築(Node.js)

Express + LINE Bot SDKでのWebhookサーバー初期設定

package.json — 依存パッケージ
{
  "name": "millie-bot",
  "version": "1.0.0",
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js"
  },
  "dependencies": {
    "@line/bot-sdk": "^9.0.0",
    "express": "^4.18.0",
    "dotenv": "^16.0.0",
    "node-cron": "^3.0.0",
    "pg": "^8.11.0"
  }
}
index.js — サーバーエントリーポイント
require('dotenv').config();
const express = require('express');
const line    = require('@line/bot-sdk');
const cron    = require('node-cron');
const { handleEvent } = require('./handlers/webhook');
const { runNightCron } = require('./handlers/cron');

const config = {
  channelSecret:      process.env.CHANNEL_SECRET,
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN,
};

const client = new line.messagingApi.MessagingApiClient({
  channelAccessToken: config.channelAccessToken,
});

const app  = express();
const PORT = process.env.PORT || 3000;

// Webhook エンドポイント
app.post('/webhook',
  line.middleware(config),
  async (req, res) => {
    try {
      await Promise.all(req.body.events.map(e => handleEvent(e, client)));
      res.json({ status: 'ok' });
    } catch (err) {
      console.error(err);
      res.status(500).end();
    }
  }
);

// CRON: 深夜 2:00 に怪異メッセージを配信
cron.schedule('0 2 * * *', () => {
  runNightCron(client);
}, { timezone: 'Asia/Tokyo' });

app.listen(PORT, () => console.log(`🖤 ミリーちゃん Bot 起動中 :${PORT}`));
03

Webhookイベントハンドラ

follow・メッセージ受信イベントの処理

handlers/webhook.js
const { getUser, createUser, updateUser } = require('../db/users');
const { getScenarioByTrigger, sendScenario } = require('./scenario');

async function handleEvent(event, client) {
  const userId = event.source.userId;

  // ─── 友だち追加 ───────────────────────────────────
  if (event.type === 'follow') {
    await createUser(userId);
    const scenario = await getScenarioByTrigger('follow', 1);
    if (scenario) await sendScenario(client, userId, scenario);
    return;
  }

  // ─── メッセージ受信 ───────────────────────────────
  if (event.type === 'message' && event.message.type === 'text') {
    const user = await getUser(userId);
    if (!user) return;

    const text = event.message.text.trim();
    await updateUser(userId, { last_reply_at: new Date() });

    // Phase 1: ニックネーム取得
    if (user.phase === 1 && !user.nickname) {
      const nickname = text.replace(/[ちゃんさんくん]$/,'').substring(0, 10);
      await updateUser(userId, { nickname, phase: 1 });
      const scenario = await getScenarioByTrigger('reply_name', 1);
      if (scenario) await sendScenario(client, userId, scenario, nickname);
      return;
    }

    // Phase 2 以降: 返信があったらフェーズを進める
    if (user.phase >= 2) {
      const scenario = await getScenarioByTrigger('reply_any', user.phase);
      if (scenario) await sendScenario(client, userId, scenario, user.nickname);
    }
  }
}

module.exports = { handleEvent };
04

シナリオエンジン

フェーズ管理・メッセージ送信・{{nickname}}置換ロジック

handlers/scenario.js
const db = require('../db');

// トリガー条件に合うシナリオをDBから取得
async function getScenarioByTrigger(trigger, phase) {
  const result = await db.query(
    `SELECT * FROM scenarios
     WHERE trigger = $1 AND phase = $2 AND is_active = true
     LIMIT 1`,
    [trigger, phase]
  );
  return result.rows[0] || null;
}

// メッセージを送信({{nickname}} を置換)
async function sendScenario(client, userId, scenario, nickname = '') {
  const message = (scenario.message || '')
    .replace(/\{\{nickname\}\}/g, nickname || 'きみ');

  await client.pushMessage({
    to: userId,
    messages: [{
      type: 'text',
      text: message,
    }],
  });

  // フェーズが上がる場合はDBを更新
  if (scenario.next_phase) {
    const { updateUser } = require('../db/users');
    await updateUser(userId, { phase: scenario.next_phase });
  }
}

// フェーズを経過日数に応じて自動進行させる
async function checkAndAdvancePhase(user) {
  const daysSince = (Date.now() - new Date(user.created_at)) / 86400000;
  const phaseThresholds = { 1: 1, 2: 3, 3: 7 };
  let newPhase = user.phase;

  for (const [phase, days] of Object.entries(phaseThresholds)) {
    if (user.phase === parseInt(phase) && daysSince >= days) {
      newPhase = parseInt(phase) + 1;
    }
  }
  return newPhase;
}

module.exports = { getScenarioByTrigger, sendScenario, checkAndAdvancePhase };
💡
{{nickname}} 置換について
シナリオメッセージ内の {{nickname}} は自動的にユーザーの名前に置換されます。 ダッシュボードのシナリオ管理画面から自由に編集できます。
05

CRON 深夜配信

深夜0〜4時帯に自動送信される生活侵食メッセージの実装

02:00
Phase 2以上のユーザーへ怪異メッセージ送信
03:33
Phase 3ユーザーへ「侵食」メッセージ(週次)
毎日
未返信チェック・エンディング条件判定
handlers/cron.js
const { getAllActiveUsers, updateUser } = require('../db/users');
const { getScenarioByTrigger, sendScenario, checkAndAdvancePhase } = require('./scenario');

async function runNightCron(client) {
  console.log('🌙 深夜CRON実行中...');
  const users = await getAllActiveUsers();

  for (const user of users) {
    try {
      // フェーズ自動進行チェック
      const newPhase = await checkAndAdvancePhase(user);
      if (newPhase !== user.phase) {
        await updateUser(user.id, { phase: newPhase });
        user.phase = newPhase;
      }

      // Phase 2以上: 深夜メッセージ
      if (user.phase >= 2) {
        const scenario = await getScenarioByTrigger(
          `time_elapsed_${user.phase === 2 ? '3d' : '7d'}`,
          user.phase
        );
        if (scenario) {
          await sendScenario(client, user.id, scenario, user.nickname);
          // 連続送信防止(1秒待機)
          await new Promise(r => setTimeout(r, 1000));
        }
      }

      // エンディング条件判定
      await checkEndingConditions(client, user);

    } catch (err) {
      console.error(`❌ ユーザー ${user.id} の処理エラー:`, err.message);
    }
  }
}

async function checkEndingConditions(client, user) {
  const daysSinceLastReply = user.last_reply_at
    ? (Date.now() - new Date(user.last_reply_at)) / 86400000
    : 999;
  const daysSinceJoin = (Date.now() - new Date(user.created_at)) / 86400000;

  // 14日間返信なし → ignore エンディング
  if (daysSinceLastReply >= 14 && user.phase >= 3 && user.ending_type === 'none') {
    await updateUser(user.id, { ending_type: 'ignore', is_active: false });
    const scenario = await getScenarioByTrigger('no_reply_14d', 4);
    if (scenario) await sendScenario(client, user.id, scenario, user.nickname);
    return;
  }

  // 30日経過で招待未達 → silent エンディング
  if (daysSinceJoin >= 30 && user.invite_count < 3 && user.ending_type === 'none') {
    await updateUser(user.id, { ending_type: 'silent', is_active: false });
    const scenario = await getScenarioByTrigger('no_invite_30d', 4);
    if (scenario) await sendScenario(client, user.id, scenario, user.nickname);
  }
}

module.exports = { runNightCron };
06

招待・拡散ロジック

「1ヶ月以内に3人へ紹介」条件の実装

handlers/invite.js — 招待URLの発行と追跡
const crypto = require('crypto');
const db     = require('../db');
const { updateUser, getUser } = require('../db/users');

// 招待URLを生成してユーザーへ送信
async function generateInviteUrl(client, userId, nickname) {
  const token = crypto.randomBytes(8).toString('hex');

  // DBにトークンを保存
  await db.query(
    `INSERT INTO invite_tokens (token, inviter_id, created_at)
     VALUES ($1, $2, NOW())`,
    [token, userId]
  );

  const inviteUrl = `https://line.me/R/ti/p/@YOUR_BOT_ID?ref=${token}`;

  await client.pushMessage({
    to: userId,
    messages: [{
      type: 'text',
      text: `${nickname || 'きみ'}ちゃんだけにおしえてあげる。\n\nこのURL、3人だけにおくってね。\n\n${inviteUrl}\n\n…かならずだよ。`,
    }],
  });
}

// 新規ユーザーの招待元を特定してカウント
async function processInviteTracking(newUserId, refToken) {
  if (!refToken) return;

  const result = await db.query(
    `SELECT inviter_id FROM invite_tokens WHERE token = $1`,
    [refToken]
  );
  if (!result.rows.length) return;

  const inviterId = result.rows[0].inviter_id;
  const inviter   = await getUser(inviterId);
  if (!inviter) return;

  const newCount = (inviter.invite_count || 0) + 1;
  await updateUser(inviterId, { invite_count: newCount });

  // 3人達成 → 拡散エンディングへ
  if (newCount >= 3 && inviter.ending_type === 'none') {
    await updateUser(inviterId, { ending_type: 'spread' });
    // spread エンディングシナリオを送信
    const { getScenarioByTrigger, sendScenario } = require('./scenario');
    const scenario = await getScenarioByTrigger('invite_count_3', 3);
    if (scenario) {
      const { client: lineClient } = require('../index');
      await sendScenario(lineClient, inviterId, scenario, inviter.nickname);
    }
  }
}

module.exports = { generateInviteUrl, processInviteTracking };
07

エンディング分岐

3つのエンディングとそれぞれのトリガー条件

🔗
SPREAD ENDING
拡散エンディング
トリガー: 1ヶ月以内に3人招待達成
「みんなにつたえてくれてありがとう。ミリー、うれしかった。でも{{nickname}}ちゃん…ずっとそこにいるから。」
🚫
IGNORE ENDING
無視エンディング
トリガー: Phase 3以降 14日間返信なし
「…もうおそいかもしれないけど。ミリー、ずっとまってたよ。」
🌑
SILENT ENDING
放置エンディング
トリガー: 30日経過で招待数3人未達
「おわりだよ。{{nickname}}ちゃんがえらんだけっまつ。さよなら。」
⚙️

環境変数

.envファイルに設定する変数の一覧

.env
# LINE Bot
CHANNEL_SECRET=your_channel_secret_here
CHANNEL_ACCESS_TOKEN=your_long_lived_access_token_here

# Server
PORT=3000
NODE_ENV=production

# Database (Supabase 例)
DATABASE_URL=postgresql://user:password@host:5432/millie_db

# Bot Settings
BOT_LINE_ID=@your_bot_id
INVITE_BASE_URL=https://line.me/R/ti/p/@your_bot_id
🔍 環境変数チェッカー(デモ)
CHANNEL_SECRET
CHANNEL_ACCESS_TOKEN

実装チェックリスト

全項目チェックでBotが完成!進捗はリアルタイムで更新されます。

📋 LINE設定
⚙️ サーバー
🗄️ データベース
🤖 Bot機能
🎉 全項目クリア!ミリーちゃんの準備完了。