diff --git a/.gitignore b/.gitignore index 5532e03..5bffdf8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .env data/*.json +uploads/ *.log verification/ diff --git a/index.js b/index.js index 42242b8..67d5d82 100644 --- a/index.js +++ b/index.js @@ -1,15 +1,9 @@ require('dotenv').config(); const express = require('express'); -const line = require('@line/bot-sdk'); +const { middleware, messagingApi } = require('@line/bot-sdk'); const path = require('path'); -const fs = require('fs'); -const cron = require('node-cron'); -const { DateTime } = require('luxon'); -const axios = require('axios'); const botLogic = require('./src/botLogic'); -const timetableData = require('./src/timetable'); -const googleCalendar = require('./src/googleCalendar'); const config = { channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN, @@ -17,239 +11,17 @@ const config = { }; const app = express(); -const client = new line.Client(config); - -app.use(express.static(path.join(__dirname, 'public'))); -app.use('/images', express.static(path.join(__dirname, 'images'))); - -const SUBSCRIBERS_FILE = path.join(__dirname, 'data/subscribers.json'); -const CHATS_FILE = path.join(__dirname, 'data/chats.json'); - -// API to get events -app.get('/api/events', async (req, res) => { - try { - const year = parseInt(req.query.year) || DateTime.now().setZone('Asia/Tokyo').year; - const month = parseInt(req.query.month) || DateTime.now().setZone('Asia/Tokyo').month; - const calendarIds = (process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_ID || '').split(',').filter(id => id.trim()); - - if (calendarIds.length === 0) { - return res.json({ success: true, data: [] }); - } - - const events = await googleCalendar.getEventsForMonth(calendarIds, year, month); - res.json({ success: true, data: events }); - } catch (err) { - console.error('API Error:', err); - res.status(500).json({ success: false, error: err.message }); - } +const client = new messagingApi.MessagingApiClient({ + channelAccessToken: config.channelAccessToken }); - -// Root route - Modernized Web Dashboard -app.get('/', (req, res) => { - const lastUpdate = DateTime.now().setZone('Asia/Tokyo').toFormat('yyyy/MM/dd HH:mm:ss'); - - const html = ` - - -
- - -毎日の授業をもっとスマートに確認
-Status
-稼働中
-Last Sync
-${lastUpdate}
-Auto Notification
-毎日 07:00 JST
-| 曜日 | -1限 | -2限 | -3限 | -4限 | -5限 | -6限 | -
|---|---|---|---|---|---|---|
| ${day} | - ${subjects.map(s => `${s} | `).join('')} - ${subjects.length < 6 ? Array(6 - subjects.length).fill('- | ').join('') : ''} -
今日の時間割 / 今日の行事
-直近の授業や行事を即座に確認できます
-明日の時間割 / 明日の行事
-翌日の予定を先取りしてチェック
-〇曜日の時間割
-特定の曜日の授業をチェック
-モーニング通知
-毎朝7時に自動で通知が届きます
-QRコードをスキャンして、
あなたのLINEに時間割を。
- SHS 2D Class Bot Project
-Invalid password.
'); - } - - let subscribersCount = 0; - if (fs.existsSync(SUBSCRIBERS_FILE)) { - subscribersCount = JSON.parse(fs.readFileSync(SUBSCRIBERS_FILE, 'utf8')).length; - } - - let chats = {}; - if (fs.existsSync(CHATS_FILE)) { - chats = JSON.parse(fs.readFileSync(CHATS_FILE, 'utf8')); - } - const chatsCount = Object.keys(chats).length; - - const html = ` - - - - -Subscribers
-${subscribersCount}
-Total Chats (Groups/Users)
-${chatsCount}
-Messages are being sent to all tracked chats.
Back to Dashboard'); -}); - -// Self-pinging to stay awake on Koyeb (every 10 minutes) -const SITE_URL = process.env.SITE_URL; -if (SITE_URL) { - cron.schedule('*/10 * * * *', async () => { - try { - console.log(`Self-pinging: ${SITE_URL}`); - await axios.get(SITE_URL); - } catch (err) { - console.error('Self-ping error:', err.message); - } - }); -} - -// Morning Notification Cron Job (7:00 AM JST) -cron.schedule('0 7 * * *', async () => { - console.log('Running morning notification task...'); - try { - if (fs.existsSync(SUBSCRIBERS_FILE)) { - const subscribers = JSON.parse(fs.readFileSync(SUBSCRIBERS_FILE, 'utf8')); - const timetableFlex = await botLogic.getTodayTimetable(); - - console.log(`Sending morning notification to ${subscribers.length} subscribers`); - - for (const id of subscribers) { - try { - await client.pushMessage(id, timetableFlex); - } catch (pushErr) { - console.error(`Failed to push to ${id}:`, pushErr.message); - } - } - } - } catch (err) { - console.error('Cron job error:', err); - } -}, { - scheduled: true, - timezone: "Asia/Tokyo" +// Basic Root Route +app.get('/', (req, res) => { + res.send('The bot is running.
'); }); const port = process.env.PORT || 3000; diff --git a/package.json b/package.json index 2c38f8b..dcfaf2f 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,6 @@ "dependencies": { "@line/bot-sdk": "^9.8.0", "dotenv": "^17.3.1", - "axios": "^1.7.9", - "express": "^5.1.0", - "ical.js": "^2.1.0", - "luxon": "^3.7.2", - "node-cron": "^3.0.3", - "node-schedule": "^2.1.1" + "express": "^5.1.0" } } diff --git a/public/QR.png b/public/QR.png deleted file mode 100644 index 6359ddd..0000000 Binary files a/public/QR.png and /dev/null differ diff --git a/src/botLogic.js b/src/botLogic.js index 137c6e0..17afd89 100644 --- a/src/botLogic.js +++ b/src/botLogic.js @@ -1,199 +1,108 @@ -const { DateTime } = require('luxon'); -const timetableData = require('./timetable'); -const flexTemplates = require('./flexTemplates'); -const googleCalendar = require('./googleCalendar'); const fs = require('fs'); const path = require('path'); +const flexTemplates = require('./flexTemplates'); -const SUBSCRIBERS_FILE = path.join(__dirname, '../data/subscribers.json'); +const UPLOADS_DIR = path.join(__dirname, '../uploads'); const CHATS_FILE = path.join(__dirname, '../data/chats.json'); -const validCommands = [ - '使い方', 'ヘルプ', '今日の時間割', '明日の時間割', - '今日の行事', '明日の行事', - '月曜日の時間割', '火曜日の時間割', '水曜日の時間割', - '木曜日の時間割', '金曜日の時間割', '土曜日の時間割', '日曜日の時間割', - '通知オン', '通知オフ' -]; +// Ensure uploads directory exists +if (!fs.existsSync(UPLOADS_DIR)) { + fs.mkdirSync(UPLOADS_DIR, { recursive: true }); +} /** * Handle incoming LINE events */ -async function handleEvent(event, client) { - // Track chats on follow/join/leave/unfollow events - if (['follow', 'join'].includes(event.type)) { - const chatId = event.source.userId || event.source.groupId || event.source.roomId; - saveChat(chatId, event.source.type); - if (event.type === 'join') { - return client.replyMessage(event.replyToken, { +async function handleEvent(event, client, blobClient) { + const chatId = event.source.userId || event.source.groupId || event.source.roomId; + saveChat(chatId, event.source.type); + + if (event.type === 'follow' || event.type === 'join') { + return client.replyMessage({ + replyToken: event.replyToken, + messages: [{ type: 'text', - text: 'はじめまして!時間割Botです。「使い方」と送ると、できることを確認できます。' - }); - } - return null; + text: 'はじめまして!寄せ書き受付ボットです。寄せ書きのファイル(画像やPDFなど)を送ってください!' + }] + }); } - if (['unfollow', 'leave'].includes(event.type)) { - const chatId = event.source.userId || event.source.groupId || event.source.roomId; + if (event.type === 'unfollow' || event.type === 'leave') { removeChat(chatId); - removeSubscriber(chatId); - return null; - } - - if (event.type !== 'message' || event.message.type !== 'text') { - return null; - } - - const userMessage = event.message.text.trim(); - const source = event.source; - let chatId; - let isGroup = false; - - if (source.type === 'user') { - chatId = source.userId; - } else if (source.type === 'group') { - chatId = source.groupId; - isGroup = true; - } else if (source.type === 'room') { - chatId = source.roomId; - isGroup = true; - } - - // Filter non-commands in groups - if (isGroup && !validCommands.includes(userMessage)) { return null; } - // Ensure chat is tracked - saveChat(chatId, source.type); - - let reply; - - if (userMessage === '使い方' || userMessage === 'ヘルプ') { - reply = flexTemplates.createHelpFlex(); - } else if (userMessage === '今日の時間割') { - reply = await getTodayTimetable(); - } else if (userMessage === '明日の時間割') { - reply = await getTomorrowTimetable(); - } else if (userMessage === '今日の行事') { - reply = await getTodayEvents(); - } else if (userMessage === '明日の行事') { - reply = await getTomorrowEvents(); - } else if (userMessage === '通知オン') { - const added = saveSubscriber(chatId); - return client.replyMessage(event.replyToken, { - type: 'text', - text: added ? '毎朝7時の時間割通知をオンにしました。' : '既に通知はオンになっています。', - quickReply: flexTemplates.getQuickReplies() - }); - } else if (userMessage === '通知オフ') { - const removed = removeSubscriber(chatId); - return client.replyMessage(event.replyToken, { - type: 'text', - text: removed ? '時間割通知をオフにしました。再度オンにするには「通知オン」と送ってください。' : '通知は既にオフになっています。', - quickReply: flexTemplates.getQuickReplies() - }); - } else if (userMessage.endsWith('曜日の時間割')) { - const day = userMessage.replace('の時間割', ''); - reply = await getTimetableForDay(day); - } else { - if (!isGroup) { - return client.replyMessage(event.replyToken, { - type: 'text', - text: '「使い方」と入力すると、使用可能なコマンドが表示されます。', - quickReply: flexTemplates.getQuickReplies() - }); + // Handle message events + if (event.type === 'message') { + const message = event.message; + + // Handle image, video, file, audio + if (['image', 'file', 'video', 'audio'].includes(message.type)) { + try { + const stream = await blobClient.getMessageContent(message.id); + const unsafeFileName = message.fileName || `${Date.now()}_${chatId.substring(0, 8)}.${getFileExtension(message.type)}`; + const fileName = path.basename(unsafeFileName); + const filePath = path.join(UPLOADS_DIR, fileName); + + const writer = fs.createWriteStream(filePath); + + await new Promise((resolve, reject) => { + stream.pipe(writer); + writer.on('finish', resolve); + writer.on('error', reject); + }); + + console.log(`Saved file: ${fileName}`); + return client.replyMessage({ + replyToken: event.replyToken, + messages: [{ + type: 'text', + text: `寄せ書きを受け取りました!ありがとうございます。\n保存名: ${fileName}` + }] + }); + } catch (err) { + console.error('Error saving file:', err); + return client.replyMessage({ + replyToken: event.replyToken, + messages: [{ + type: 'text', + text: 'ファイルの保存中にエラーが発生しました。もう一度試してみてください。' + }] + }); + } } - return null; - } - - // Add quick replies to all replies - reply.quickReply = flexTemplates.getQuickReplies(); - - return client.replyMessage(event.replyToken, reply); -} - -async function getTodayTimetable() { - const now = DateTime.now().setZone('Asia/Tokyo'); - const dayOfWeek = ['日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日'][now.weekday % 7]; - return getTimetableForDay(dayOfWeek, now); -} - -async function getTomorrowTimetable() { - const tomorrow = DateTime.now().setZone('Asia/Tokyo').plus({ days: 1 }); - const dayOfWeek = ['日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日'][tomorrow.weekday % 7]; - return getTimetableForDay(dayOfWeek, tomorrow); -} - -async function getTodayEvents() { - const now = DateTime.now().setZone('Asia/Tokyo'); - const calendarIds = (process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_ID || '').split(',').filter(id => id.trim()); - const events = calendarIds.length > 0 ? await googleCalendar.getEventsForDate(calendarIds, now) : []; - return flexTemplates.createEventsFlex('今日', events); -} -async function getTomorrowEvents() { - const tomorrow = DateTime.now().setZone('Asia/Tokyo').plus({ days: 1 }); - const calendarIds = (process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_ID || '').split(',').filter(id => id.trim()); - const events = calendarIds.length > 0 ? await googleCalendar.getEventsForDate(calendarIds, tomorrow) : []; - return flexTemplates.createEventsFlex('明日', events); -} + // Handle text messages + if (message.type === 'text') { + const userMessage = message.text.trim(); -async function getTimetableForDay(dayOfWeek, date = null) { - const subjects = timetableData[dayOfWeek] || []; - let events = []; + if (userMessage === '使い方' || userMessage === 'ヘルプ') { + return client.replyMessage({ + replyToken: event.replyToken, + messages: [flexTemplates.createHelpFlex()] + }); + } - if (date) { - const calendarIds = (process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_ID || '').split(',').filter(id => id.trim()); - if (calendarIds.length > 0) { - events = await googleCalendar.getEventsForDate(calendarIds, date); + // Default response for text + return client.replyMessage({ + replyToken: event.replyToken, + messages: [{ + type: 'text', + text: '寄せ書きのファイルや画像を送ってください。「使い方」と送るとヘルプを表示します。' + }] + }); } } - return flexTemplates.createTimetableFlex(dayOfWeek, subjects, events); -} - -function saveSubscriber(id) { - if (!id) return false; - try { - if (!fs.existsSync(path.dirname(SUBSCRIBERS_FILE))) { - fs.mkdirSync(path.dirname(SUBSCRIBERS_FILE), { recursive: true }); - } - - let subscribers = []; - if (fs.existsSync(SUBSCRIBERS_FILE)) { - subscribers = JSON.parse(fs.readFileSync(SUBSCRIBERS_FILE, 'utf8')); - } - - if (!subscribers.includes(id)) { - subscribers.push(id); - fs.writeFileSync(SUBSCRIBERS_FILE, JSON.stringify(subscribers, null, 2)); - console.log(`New subscriber added: ${id}`); - return true; - } - return false; - } catch (err) { - console.error('Error saving subscriber:', err); - return false; - } + return null; } -function removeSubscriber(id) { - if (!id) return false; - try { - if (fs.existsSync(SUBSCRIBERS_FILE)) { - let subscribers = JSON.parse(fs.readFileSync(SUBSCRIBERS_FILE, 'utf8')); - if (subscribers.includes(id)) { - subscribers = subscribers.filter(sid => sid !== id); - fs.writeFileSync(SUBSCRIBERS_FILE, JSON.stringify(subscribers, null, 2)); - console.log(`Subscriber removed: ${id}`); - return true; - } - } - return false; - } catch (err) { - console.error('Error removing subscriber:', err); - return false; +function getFileExtension(type) { + switch (type) { + case 'image': return 'jpg'; + case 'video': return 'mp4'; + case 'audio': return 'm4a'; + default: return 'bin'; } } @@ -242,6 +151,5 @@ function removeChat(id) { } module.exports = { - handleEvent, - getTodayTimetable + handleEvent }; diff --git a/src/flexTemplates.js b/src/flexTemplates.js index 96eaf6c..4429265 100644 --- a/src/flexTemplates.js +++ b/src/flexTemplates.js @@ -1,133 +1,11 @@ /** - * Flex Message templates for the LINE Bot + * Flex Message templates for the Yosegaki LINE Bot */ -function createTimetableFlex(day, subjects, events = []) { - const isWeekend = subjects.length === 0; - - const bodyContents = []; - - // Add subjects - if (isWeekend) { - bodyContents.push({ - "type": "text", - "text": `${day}は授業がありません。`, - "size": "md", - "color": "#666666", - "wrap": true - }); - } else { - subjects.forEach((subject, index) => { - bodyContents.push({ - "type": "box", - "layout": "horizontal", - "contents": [ - { - "type": "text", - "text": `${index + 1}限`, - "size": "sm", - "color": "#888888", - "flex": 1 - }, - { - "type": "text", - "text": subject || "-", - "size": "md", - "color": "#333333", - "flex": 4, - "weight": "bold", - "wrap": true - } - ], - "margin": "md", - "paddingBottom": "sm" - }); - }); - } - - // Add events if any - if (events && events.length > 0) { - bodyContents.push({ - "type": "separator", - "margin": "xl" - }); - bodyContents.push({ - "type": "text", - "text": "📅 本日の行事", - "weight": "bold", - "size": "sm", - "margin": "md", - "color": "#4CAF50" - }); - - events.forEach(event => { - bodyContents.push({ - "type": "box", - "layout": "vertical", - "contents": [ - { - "type": "text", - "text": event.summary, - "size": "sm", - "weight": "bold", - "wrap": true - } - ], - "margin": "sm", - "paddingStart": "md" - }); - }); - } - - return { - "type": "flex", - "altText": `【${day}の時間割】`, - "contents": { - "type": "bubble", - "header": { - "type": "box", - "layout": "vertical", - "contents": [ - { - "type": "text", - "text": day, - "weight": "bold", - "size": "xl", - "color": "#ffffff" - } - ], - "backgroundColor": "#4CAF50" - }, - "body": { - "type": "box", - "layout": "vertical", - "contents": bodyContents - }, - "footer": { - "type": "box", - "layout": "vertical", - "spacing": "sm", - "contents": [ - { - "type": "button", - "style": "link", - "height": "sm", - "action": { - "type": "uri", - "label": "詳細をサイトで見る", - "uri": process.env.SITE_URL || "https://shs2d-linebot.aeroindust.com" - } - } - ] - } - } - }; -} - function createHelpFlex() { return { "type": "flex", - "altText": "【時間割ボットの使い方】", + "altText": "【寄せ書きボットの使い方】", "contents": { "type": "bubble", "header": { @@ -142,7 +20,7 @@ function createHelpFlex() { "color": "#ffffff" } ], - "backgroundColor": "#2196F3" + "backgroundColor": "#FF9800" }, "body": { "type": "box", @@ -151,7 +29,7 @@ function createHelpFlex() { "contents": [ { "type": "text", - "text": "以下のコマンドを送信するか、下のボタンをタップしてください。", + "text": "寄せ書きのファイルや画像を受け付けています。", "wrap": true, "size": "sm" }, @@ -160,53 +38,10 @@ function createHelpFlex() { }, { "type": "text", - "text": "• 今日の時間割\n• 今日の行事\n• 明日の時間割 / 行事\n• 〇曜日の時間割\n• 通知オン / 通知オフ", + "text": "• 画像、動画、音声、ファイルを送信してください。\n• 受信したファイルは自動的に保存されます。\n• テキストメッセージは現在受け付けていません。", "wrap": true, - "margin": "md" - } - ] - }, - "footer": { - "type": "box", - "layout": "vertical", - "spacing": "sm", - "contents": [ - { - "type": "button", - "style": "primary", - "color": "#4CAF50", - "action": { - "type": "message", - "label": "今日の時間割", - "text": "今日の時間割" - } - }, - { - "type": "box", - "layout": "horizontal", - "spacing": "sm", - "contents": [ - { - "type": "button", - "style": "secondary", - "action": { - "type": "message", - "label": "通知オン", - "text": "通知オン" - }, - "flex": 1 - }, - { - "type": "button", - "style": "secondary", - "action": { - "type": "message", - "label": "通知オフ", - "text": "通知オフ" - }, - "flex": 1 - } - ] + "margin": "md", + "size": "sm" } ] } @@ -214,140 +49,6 @@ function createHelpFlex() { }; } -function getQuickReplies() { - return { - "items": [ - { - "type": "action", - "action": { - "type": "message", - "label": "今日の予定", - "text": "今日の時間割" - } - }, - { - "type": "action", - "action": { - "type": "message", - "label": "今日の行事", - "text": "今日の行事" - } - }, - { - "type": "action", - "action": { - "type": "message", - "label": "明日", - "text": "明日の時間割" - } - }, - { - "type": "action", - "action": { - "type": "message", - "label": "通知ON", - "text": "通知オン" - } - }, - { - "type": "action", - "action": { - "type": "message", - "label": "通知OFF", - "text": "通知オフ" - } - }, - { - "type": "action", - "action": { - "type": "message", - "label": "ヘルプ", - "text": "ヘルプ" - } - } - ] - }; -} - -function createEventsFlex(day, events = []) { - const bodyContents = []; - - if (events.length === 0) { - bodyContents.push({ - "type": "text", - "text": `${day}に予定されている行事はありません。`, - "size": "md", - "color": "#666666", - "wrap": true - }); - } else { - events.forEach(event => { - bodyContents.push({ - "type": "box", - "layout": "vertical", - "contents": [ - { - "type": "text", - "text": event.summary, - "weight": "bold", - "size": "md", - "wrap": true - } - ], - "margin": "md", - "backgroundColor": "#f0fdf4", - "cornerRadius": "md", - "paddingAll": "md" - }); - if (event.location || event.description) { - const details = []; - if (event.location) details.push(`📍 ${event.location}`); - if (event.description) details.push(event.description); - - bodyContents.push({ - "type": "text", - "text": details.join('\n'), - "size": "xs", - "color": "#888888", - "wrap": true, - "margin": "sm", - "paddingStart": "md" - }); - } - }); - } - - return { - "type": "flex", - "altText": `【${day}の行事予定】`, - "contents": { - "type": "bubble", - "header": { - "type": "box", - "layout": "vertical", - "contents": [ - { - "type": "text", - "text": `${day} の行事`, - "weight": "bold", - "size": "xl", - "color": "#ffffff" - } - ], - "backgroundColor": "#4CAF50" - }, - "body": { - "type": "box", - "layout": "vertical", - "contents": bodyContents - } - } - }; -} - module.exports = { - createTimetableFlex, - createHelpFlex, - getQuickReplies, - createEventsFlex + createHelpFlex }; diff --git a/src/googleCalendar.js b/src/googleCalendar.js deleted file mode 100644 index 3013bf2..0000000 --- a/src/googleCalendar.js +++ /dev/null @@ -1,103 +0,0 @@ -const axios = require('axios'); -const ICAL = require('ical.js'); -const { DateTime } = require('luxon'); - -/** - * Fetches and parses Google Calendar iCal feeds. - * @param {string[]} calendarIds Array of Google Calendar IDs. - * @param {DateTime} targetDate The date to filter events for. - * @returns {Promise