🔴 未設定
00
概要・アーキテクチャ
ミリーちゃんLINE BotのシステムアーキテクチャとTechStackの全体像
LINEユーザー
→
LINE Platform
Messaging API
Messaging API
⇄
Webhook Server
Node.js / Python
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チャネルの作成とトークン取得
-
1LINE Developers Console にアクセスLINEアカウントでログインし、プロバイダーを作成
-
2新しいチャネルを作成チャネルの種類:
Messaging APIを選択チャネル名:ミリーちゃん / カテゴリ:エンターテインメント -
3各種トークンを取得・保存「チャネル基本設定」と「Messaging API設定」タブから以下を取得:CHANNEL_SECRET チャネル基本設定 → チャネルシークレットCHANNEL_ACCESS_TOKEN Messaging API設定 → チャネルアクセストークン(長期)
-
4Webhook URLを設定サーバーデプロイ後に設定(Step 2完了後)Webhook URL形式
https://your-server.railway.app/webhook -
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機能
🎉
全項目クリア!ミリーちゃんの準備完了。