😺

Next.js cron-job.orgで毎朝カレンダー通知するslack bot作成してみた

2024/07/10に公開

記事の内容

Next.js Vercel cron-job.orgを使用して毎朝カレンダー通知するslack bot作成する方法を紹介します!完成イメージはこんな感じ本当はメールアドレスとか詳細も表示されるように実装しているが個人情報を隠すため情報量が少ない画像を載せています!!!

対象読者

Next.jsを触ったことがあり何かしら作ってみたい方
slackのbot機能開発に興味のある方

実装方法

プロジェクトを立ち上げる

# npx
npx create-next-app@latest
# yarn
yarn create next-app

ディレクトリ構成

my-next-app/
├── lib/
│   ├── googleCalendar.js
│   └── slack.js
├── pages/
│   ├── api/
│   │   └── cron.js
│   └── index.js
├── .env
├── package.json
├── next.config.js
└── README.md

Slack API Keyの取得

https://zenn.dev/kou_pg_0131/articles/slack-api-post-message
この方の記事を参考にslackのapi key取得及び設定を行う

実装コード

cron.js

import { authorize, getEvents } from '../../lib/googleCalendar';
import { sendReminder } from '../../lib/slack';
import moment from 'moment-timezone';
import 'moment/locale/ja'; 

moment.locale('ja'); 

export default async function handler(req, res) {
  res.setHeader('Cache-Control', 'no-store, max-age=0');
  res.setHeader('Pragma', 'no-cache');
  res.setHeader('Expires', '0');
  res.setHeader('Surrogate-Control', 'no-store');

  try {
    if (req.method === 'GET') {

      const auth = authorize();

      const events = await getEvents(auth);

      const getTokyoDate = (offsetDays = 0) => {
        const tokyoDate = moment.tz('Asia/Tokyo').add(offsetDays, 'days');
      
        return tokyoDate.format('YYYY-MM-DD');
      };

      const today = getTokyoDate();
      const tomorrow = getTokyoDate(1);
      const nextWeek = getTokyoDate(7);

      const todayEvents = events.filter(event => event.start.dateTime && event.start.dateTime.startsWith(today));
      const tomorrowEvents = events.filter(event => event.start.dateTime && event.start.dateTime.startsWith(tomorrow));
      const nextWeekEvents = events.filter(event => event.start.dateTime && event.start.dateTime.startsWith(nextWeek));


      const formatDate = (dateString) => {
        return moment.tz(dateString, 'Asia/Tokyo').format('YYYY年M月D日(dddd)');
      };

      const formatTime = (dateTime) => {
        return moment.tz(dateTime, 'Asia/Tokyo').format('HH:mm');
      };

      const formatAttendees = (attendees) => {
        return attendees.map(a => a.displayName || a.email).join(', ');
      };

      const formatEvent = (event) => {
        let eventDetails = `- ${event.summary} at ${formatTime(event.start.dateTime)}`;
        if (event.location) {
          eventDetails += `\n  場所: ${event.location}`;
        }
        if (event.description) {
          eventDetails += `\n  説明: ${event.description}`;
        }
        if (event.attendees && event.attendees.length > 0) {
          eventDetails += `\n  参加者: ${formatAttendees(event.attendees)}`;
        }
        if (event.creator) {
          eventDetails += `\n  作成者: ${event.creator.displayName || event.creator.email}`;
        }
        if (event.organizer) {
          eventDetails += `\n  主催者: ${event.organizer.displayName || event.organizer.email}`;
        }
        if (event.hangoutLink) {
          eventDetails += `\n  Google Meetリンク: ${event.hangoutLink}`;
        }
        if (event.htmlLink) {
          eventDetails += `\n  詳細リンク: ${event.htmlLink}`;
        }
        
        if (event.description && event.description.includes('@channel')) {
          eventDetails += `\n  <!channel> @channel`;
        }
        return eventDetails;
      };

      const formatEventSection = (title, events) => {
        let section = `*${title}*\n`;
        if (events.length === 0) {
          section += 'なし\n';
        } else {
          events.forEach(event => {
            section += `${formatEvent(event)}\n\n`;
          });
        }
        return section;
      };

      const currentTokyoTime = moment.tz('Asia/Tokyo').format('YYYY年M月D日(dddd) HH:mm');

      let message = `:spiral_calendar_pad: *現在のTokyo時間* (${currentTokyoTime}) :spiral_calendar_pad:\n\n`;
      message += `:spiral_calendar_pad: *今日の予定* (${formatDate(today)}) :spiral_calendar_pad:\n`;
      message += '```\n';
      message += formatEventSection('今日の予定', todayEvents);
      message += '```\n\n';

      message += `:spiral_calendar_pad: *明日の予定* (${formatDate(tomorrow)}) :spiral_calendar_pad:\n`;
      message += '```\n';
      message += formatEventSection('明日の予定', tomorrowEvents);
      message += '```\n\n';

      message += `:spiral_calendar_pad: *来週の予定* (${formatDate(nextWeek)}) :spiral_calendar_pad:\n`;
      message += '```\n';
      message += formatEventSection('来週の予定', nextWeekEvents);
      message += '```\n';

      await sendReminder(message);

      res.status(200).json({ message: 'Reminders sent successfully!' });
    } else {
      res.status(405).json({ message: 'Method not allowed' });
    }
  } catch (error) {
    console.error('Error in handler:', error);
    res.status(500).json({ error: error.message });
  }
}

slack.js

import { WebClient } from '@slack/web-api';

const slackClient = new WebClient(process.env.SLACK_BOT_TOKEN);

export async function sendReminder(message) {
  await slackClient.chat.postMessage({
    channel: process.env.SLACK_CHANNEL_ID,
    text: message,
    icon_emoji: ':bot:', 
    mrkdwn: true 
  });
}

googleCalendar.js

import { google } from 'googleapis';
import moment from 'moment-timezone';

const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'];
const calendar = google.calendar('v3');

export async function getEvents(auth) {
  const calendarId = 'primary';
  const tokyoTimeMin = moment().tz('Asia/Tokyo').toISOString(true); 

  const res = await calendar.events.list({
    calendarId: calendarId,
    timeMin: tokyoTimeMin, // 東京タイムゾーンを考慮したtimeMinを設定
    maxResults: 10,
    singleEvents: true,
    orderBy: 'startTime',
    auth: auth // 認証情報を追加
  });

  // ここでレスポンスヘッダーの日付をJSTに変換する
  const serverDate = moment(res.headers.date).tz('Asia/Tokyo').format('YYYY-MM-DDTHH:mm:ssZ');
  const expiresDate = moment(res.headers.expires).tz('Asia/Tokyo').format('YYYY-MM-DDTHH:mm:ssZ');

  return res.data.items;
}

export function authorize() {
  const { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN } = process.env;
  const oAuth2Client = new google.auth.OAuth2(
    GOOGLE_CLIENT_ID,
    GOOGLE_CLIENT_SECRET
  );
  oAuth2Client.setCredentials({ refresh_token: GOOGLE_REFRESH_TOKEN });
  return oAuth2Client;
}

.env

取得した自分のものを設定してください

SLACK_BOT_TOKEN=
SLACK_CHANNEL_ID=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REFRESH_TOKEN=

Discussion