NotionとLINE Notifyでカレンダーの予定を通知する

# 0. 目次
1. はじめに
1. 必要なもの
1. 使用技術
1. 解説
    1. SDKを用意
    1. 使用するライブラリのインストール
    1. 環境変数を読み込ませる
    1. Notionから予定を取得する
    1. LINEメッセージの生成
    1. LINEに送る
1. まとめ


# はじめに
MYJLab Advent Calendar 2022 23日目の記事を担当するのぶです。
昨日の@Kuroi_ccさんの記事は、「nginxでhttpsサイトを公開しよう！」でした。

MYJLab Advent Calendar 2022の23日目はNotionカレンダーに登録されている予定をLINEに通知してみようと思います。
私は普段Notionカレンダーを使って予定を管理しています。
NotionカレンダーはPC版だと見やすくて使い勝手がいいのですが、スマホ版だと一目で予定が確認しづらいです。
そこで今回はNotionカレンダーに登録されている当日の予定をラインで確認できるようにすることでこの課題を解決したいと思います！
[全体のソースコード](https://github.com/llldagolll/today-my-schedule)
```plaintext
# こんな感じでラインに送られてくる
[Today-My-Schedule] 
 今日の予定です
 ---------------------------
 10:00 今日の予定1 (url)
,10:00~20:00 今日の予定2 (url)
,2022-12-23~2022-12-24 今日の予定3 (url)
,終日 今日の予定4 (url)


```

# 必要なもの
- [LINE Notify Token](https://notify-bot.line.me/ja/)
- [Notion Api Token](https://www.notion.so/ja-jp/help/create-integrations-with-the-notion-api)
- [Notion Calendar のデータベースID](https://booknotion.site/setting-databaseid)

これらを事前に取得しておく必要があります！
やり方はリンク先の資料を読むとわかりやすいです。

# 使用技術

|名前  |概要  |
|---|---|
|[TypeScript](https://www.typescriptlang.org/)  |JavaScriptを拡張して作られたプログラミング言語  |
|@notionhq/client  |Notion API用のライブラリ  |
|[axios](https://github.com/axios/axios)|HTTPクライアント|
|[dotenv](https://github.com/motdotla/dotenv)|Node.jsで環境変数を扱えるようにするライブラリ|
|[lodash](https://lodash.com/)|強力な関数型プログラミングの関数を提供してくれるライブラリ|
|[luxon](https://moment.github.io/luxon/#/)|JavaScriptで日時操作を楽にしてくれるライブラリ|
|[qs](https://github.com/ljharb/qs)|クエリ操作を楽にするライブラリ|
|[winston](https://github.com/winstonjs/winston)|強力なロギングライブラリ|

# 解説
## 全体の処理の流れ
処理は大きく分けて三つです！
```mermaid
graph LR
Notionから予定を取得 --> メッセージ整形
メッセージ整形 --> LINEに送る

```
## [公式のTypeScript用SDK](https://github.com/makenotion/notion-sdk-typescript-starter)を手元にダウンロードする
```bash
git clone https://github.com/makenotion/notion-sdk-typescript-starter
```

## 順番にライブラリのインストール
```bash
npm install
```
```bash
npm install axios qs winston
```
```bash
npm install --save dotenv lodash luxon
```

## 環境変数を読み込む
事前に用意したトークンやIDを.envに書きます。
```.env
NOTION_TOKEN=
LINE_TOKEN=
DATABASE_ID=
```
変数に代入するのと一緒に環境変数があるかどうかもチェックします、なかったらエラーを出して処理を終了させます。


```main.ts
// 存在もチェック
  const [notionToken, databaseId, lineToken] = isEnv([
    process.env.NOTION_TOKEN,
    process.env.DATABASE_ID,
    process.env.LINE_TOKEN,
  ]);

function isEnv(ENV: (string | undefined)[]): string[] {
  const env = ENV.map((v) => {
    if (!v) {
      throw new Error("環境変数が設定されていません");
    }
    return v;
  });
  return env;
}
```

## Notionから予定を取得する
luxonライブラリはデフォルトだとUTCになっているので、JTCにします。
Notionカレンダー（データベース）からは今日の予定の時間、タイトル、リンクを取得して、それらを組みにした配列にします。


```main.ts
  const notion = new Client({
    auth: notionToken,
  });

  const today = DateTime.now().setZone("Asia/Tokyo").toFormat("yyyy-LL-dd");

//クエリを生成
  const notionDatabaseQuery: QueryDatabaseParameters = {
    database_id: databaseId,
    filter: {
      and: [
        {
          property: "日付",
          date: {
            equals: today,
          },
        },
      ],
    },
  };




const getMyScheduleTitle = (
  todayMySchedule: QueryDatabaseResponse
): string[] => {
  const myScheduleTitle: (string | undefined)[] = todayMySchedule.results.map(
    (result) => {
      if (!("properties" in result)) return;
      if (!("title" in result.properties.Name)) return;

      return result.properties.Name.title[0].plain_text;
    }
  );

  return myScheduleTitle.filter((title): title is string => !!title);
};

const getMyScheduleTime = (
  todayMySchedule: QueryDatabaseResponse
): string[] => {
  const myScheduleDate: (string | undefined)[] = todayMySchedule.results.map(
    (result) => {
      if (!("properties" in result)) return;
      if (!("date" in result.properties["日付"])) return;
      return ReturnScheduleTime(result.properties["日付"].date);
    }
  );

  return myScheduleDate.filter((date): date is string => !!date);
};


function ReturnScheduleTime(date: any): string {
  if (check_date(date.start)) {
    if (date.end == null) return "終日"; // YYYY-MM-DD
    return `${date.start}~${date.end}`; //  YYYY-MM-DD ~ YYYY-MM-DD
  }

  if (date.end === null) {
    const start = date.start.split("T")[1].substr(0, 5);
    return `${start}`;
  } //YYYY-MM-DDT
  const start = date.start.split("T")[1].substr(0, 5);
  const end = date.end.split("T")[1].substr(0, 5);

  return `${start}~${end}`; //YYYY-MM-DDT ~ YYYY-MM-DDT
}

const check_date = (s: string) => {
  if (typeof s == "string") {
    const a = s.match(/^(\d+)\-(\d+)\-(\d+)$/);
    if (a) {
      const y = parseInt(a[1]);
      const m = parseInt(a[2]) - 1;
      const d = parseInt(a[3]);
      const x = new Date(y, m, d);
      return y == x.getFullYear() && m == x.getMonth() && d == x.getDate();
    }
  }
  return false;
};

const getMyScheduleLink = (
  todayMySchedule: QueryDatabaseResponse
): string[] => {
  const myScheduleLink: (string | undefined)[] = todayMySchedule.results.map(
    (result) => {
      if (!("url" in result)) return;
      return result.url;
    }
  );

  return myScheduleLink.filter((link): link is string => !!link);
};

  type ScheduleTime = string;
  type ScheduleTitle = string;
  type ScheduleLink = string;

//時間とタイトルとリンクを組みにする
  const schedule = (await notion.databases
    .query(notionDatabaseQuery)
    .then((s) => {
      return zip<string>(
        getMyScheduleTime(s),
        getMyScheduleTitle(s),
        getMyScheduleLink(s)
      );
    })) as [ScheduleTime, ScheduleTitle, ScheduleLink][];

  schedule.sort((a, b) => {
    if (a[0] > b[0]) {
      return 1;
    } else {
      return -1;
    }
  });
```
## LINEメッセージの生成
今日の予定の時間とタイトルとリンクを組みにした配列(タプル)をmapで回してLINEに送る用のメッセージを作ります。

```typescript
  let message: string;
  if (schedule.length === 0) {
    message = `
    今日の予定です
    ---------------------------
    なし
  `;
  } else {
    message = `
    今日の予定です
    ---------------------------
    ${schedule.map((s): string => {
      return `${s[0]} ${s[1]} (${s[2]})\n`;
    })}
  `;
  }
```

## LINEに送る
あとはLINE APIに乗っけて送るだけです！
```main.ts
const linePost = async (message: string, lineToken: string) => {
  try {
    const res = await axios({
      method: "post",
      url: "https://notify-api.line.me/api/notify",
      headers: {
        Authorization: `Bearer ${lineToken}`,
        "Content-Type": "application/x-www-form-urlencoded",
      },
      data: qs.stringify({
        message: message,
      }),
    });
  } catch (e: any) {
    const { status, statusText } = e.response;

    logger.error(`Error! HTTP Status: ${status} ${statusText}`);
  }
};

  await linePost(message, lineToken);
```

## 全体のソースコード
全部組み合わせるとこんな感じになります！
```typescript
import dotenv from "dotenv";
import {
  getAllUsers,
  getMyScheduleLink,
  getMyScheduleTime,
  getMyScheduleTitle,
  getMyUserId,
} from "./notion";
import { QueryDatabaseParameters } from "@notionhq/client/build/src/api-endpoints";
import { Client } from "@notionhq/client";
import { DateTime } from "luxon";
import { linePost } from "./line";
import { zip } from "lodash";
import logger from "./logger";

dotenv.config();

const isEnv = (ENV: (string | undefined)[]): string[] => {
  const env = ENV.map((v) => {
    if (!v) {
      throw new Error("環境変数が設定されていません");
    }

    return v;
  });

  return env;
}

const getAllUsers = async (notionToken: string) => {
  try {
    const res = await axios({
      method: "get",
      url: "https://api.notion.com/v1/users",
      headers: {
        Authorization: `Bearer ${notionToken}`,
        "Notion-Version": "2022-06-28",
      },
    });

    return res.data;
  } catch (e: any) {
    const { status, statusText } = e.response;
    logger.error(`Error! HTTP Status: ${status} ${statusText}`);
  }
};

const getMyUserId = (myName: string, users: any): string => {
  return users.results.filter((user: any) => user.name === myName)[0].id;
};

const getMyScheduleTitle = (
  todayMySchedule: QueryDatabaseResponse
): string[] => {
  const myScheduleTitle: (string | undefined)[] = todayMySchedule.results.map(
    (result) => {
      if (!("properties" in result)) return;
      if (!("title" in result.properties.Name)) return;

      console.log(result.properties.Name.title[0].plain_text);

      return result.properties.Name.title[0].plain_text;
    }
  );

  return myScheduleTitle.filter((title): title is string => !!title);
};

const getMyScheduleTime = (
  todayMySchedule: QueryDatabaseResponse
): string[] => {
  const myScheduleDate: (string | undefined)[] = todayMySchedule.results.map(
    (result) => {
      if (!("properties" in result)) return;
      if (!("date" in result.properties["日付"])) return;
      return ReturnScheduleTime(result.properties["日付"].date);
    }
  );

  return myScheduleDate.filter((date): date is string => !!date);
};


function ReturnScheduleTime(date: any): string {
  if (check_date(date.start)) {
    if (date.end == null) return "終日"; // YYYY-MM-DD
    return `${date.start}~${date.end}`; //  YYYY-MM-DD ~ YYYY-MM-DD
  }

  if (date.end === null) {
    const start = date.start.split("T")[1].substr(0, 5);
    return `${start}`;
  } //YYYY-MM-DDT
  const start = date.start.split("T")[1].substr(0, 5);
  const end = date.end.split("T")[1].substr(0, 5);

  return `${start}~${end}`; //YYYY-MM-DDT ~ YYYY-MM-DDT
}

const check_date = (s: string) => {
  if (typeof s == "string") {
    const a = s.match(/^(\d+)\-(\d+)\-(\d+)$/);
    if (a) {
      const y = parseInt(a[1]);
      const m = parseInt(a[2]) - 1;
      const d = parseInt(a[3]);
      const x = new Date(y, m, d);
      return y == x.getFullYear() && m == x.getMonth() && d == x.getDate();
    }
  }
  return false;
};

const getMyScheduleLink = (
  todayMySchedule: QueryDatabaseResponse
): string[] => {
  const myScheduleLink: (string | undefined)[] = todayMySchedule.results.map(
    (result) => {
      if (!("url" in result)) return;
      return result.url;
    }
  );

  return myScheduleLink.filter((link): link is string => !!link);
};

const linePost = async (message: string, lineToken: string) => {
  try {
    const res = await axios({
      method: "post",
      url: "https://notify-api.line.me/api/notify",
      headers: {
        Authorization: `Bearer ${lineToken}`,
        "Content-Type": "application/x-www-form-urlencoded",
      },
      data: qs.stringify({
        message: message,
      }),
    });
  } catch (e: any) {
    const { status, statusText } = e.response;

    logger.error(`Error! HTTP Status: ${status} ${statusText}`);
  }
};



const main = async () => {
  const [notionToken, databaseId, myName, lineToken] = isEnv([
    process.env.NOTION_TOKEN,
    process.env.DATABASE_ID,
    process.env.NOTION_NAME,
    process.env.LINE_TOKEN,
  ]);

  const notion = new Client({
    auth: notionToken,
  });

  const today = DateTime.now().setZone("Asia/Tokyo").toFormat("yyyy-LL-dd");


  const notionDatabaseQuery: QueryDatabaseParameters = {
    database_id: databaseId,
    filter: {
      and: [
        {
          property: "日付",
          date: {
            equals: today,
          },
        },
      ],
    },
  };

  type ScheduleTime = string;
  type ScheduleTitle = string;
  type ScheduleLink = string;

  const schedule = (await notion.databases
    .query(notionDatabaseQuery)
    .then((s) => {
      return zip<string>(
        getMyScheduleTime(s),
        getMyScheduleTitle(s),
        getMyScheduleLink(s)
      );
    })) as [ScheduleTime, ScheduleTitle, ScheduleLink][];

  schedule.sort((a, b) => {
    if (a[0] > b[0]) {
      return 1;
    } else {
      return -1;
    }
  });

  let message: string;
  if (schedule.length === 0) {
    message = `
    今日の予定です
    ---------------------------
    なし
  `;
  } else {
    message = `
    今日の予定です
    ---------------------------
    ${schedule.map((s): string => {
      return `${s[0]} ${s[1]} (${s[2]})\n`;
    })}
  `;
  }

  //line
  await linePost(message, lineToken);
}

main()
  .then(() => {
    logger.info("success");
  })
  .catch((err) => {
    logger.error(err);
  });


```




# まとめ
Notion APIは公式ドキュメントのサンプルが充実しているので、もっといろんなことができそうです。
今回は自分の生活に使えそうなものができたので満足です。
読んでいただきありがとうございましたm(_ _)m



# 参考記事
- https://github.com/makenotion/notion-sdk-typescript-starter
- https://www.notion.so/ja-jp/help/create-integrations-with-the-notion-api
- https://booknotion.site/setting-databaseid
- https://developers.notion.com/
- https://notify-bot.line.me/ja/
- https://qiita.com/KUMABOB/items/c057e6d2d06a70afb661