📑

【NotionAPI×DiscordAPI×GithubActions】コミュニティの定例アジェンダ自動生成&通知Bot?作成したよ

2023/03/26に公開

目的

  • コミュニティの定例アジェンダを自動生成する
  • アジェンダのタイトルに日付を追加する
  • 自動生成されたアジェンダのリンクを自動でDiscordに通知する

notion_discord.png

Contributer

repository

https://github.com/MidraLab/notion-meeting-scheduler

指針

  • テンプレートを繰り返し複製するNotionの機能で自動生成する
    https://www.notion.so/ja-jp/releases/2022-11-08
  • 定期生成された後に、GitHubActionsのcronジョブを起点にNotionのAPIを叩いて、タイトルに日付を追加しページのURLを取得する
  • DiscordのWebhookを叩いて、生成されたページのURLをDiscordに通知する

視覚化

flow_visual.png

MidraLabについてちょっと宣伝。。

当コミュニティでは、Unityを使った1週間~1ヶ月のゲーム開発や運営の自動化(定例通知botやTwitter botなど)、
最新技術や気になる情報の共有、ブログ執筆などを行っています。学生が中心のメンバーで、インターンや就活に使えるポートフォリオを充実させたい人におすすめ!
面談は一度だけ、フィーリングを確かめましょう。Unityでチーム開発もやっていて、Web制作物の共有もできるけど、業務でやってる人はいないからレベルは低めかも。
興味ある人は、ぜひTwitterのDMやコメントで連絡してくださいね!

リンク集

https://zenn.dev/p/midra_lab

https://github.com/midralab

https://twitter.com/midra_lab

https://ayousanz.notion.site/MidraLab-dd08b86fba4e4041a14e09a1d36f36ae

コード解説

担当部分はgoで書いた部分になるのでこちらの解説を中心にします。
省きながら解説していくので、わからない部分があれば、ソースコードを見てください。
普段はC#を使って開発をしているので、Goで正しくクラス分けできているかはわかりません。ご了承ください。

クラス図

img.png

NotionAPI

まずはページのタイトルを変更するためにページのIDを取得する必要があります。

そして今回はURLも必要なので一緒に取得していきます。

//notion_page_title_patch.go

type NotionAPI struct {
	DatabaseURL string
	APIKey      string
}

type notionResponse struct {
	Results []struct {
		ID  string `json:"id"`
		URL string `json:"url"`
	} `json:"Results"`
}
//(n *NotionAPI)の部分はmain.goの部分で解説します
func (n *NotionAPI) ReadPageID() (string, string, error) {
	dbUrl := "https://api.notion.com/v1/databases/" + n.DatabaseURL + "/query"

	payload := strings.NewReader(`{
    "filter": {
        "property": "会議種別",
        "multi_select": {
            "contains": "定例"
        }
    },
    "page_size": 1
}`)

	req, err := http.NewRequest("POST", dbUrl, payload)
	if err != nil {
		return "", "", err
	}

	req.Header.Add("accept", "application/json")
	req.Header.Add("Authorization", "Bearer "+n.APIKey)
	req.Header.Add("Notion-Version", "2022-06-28")
	req.Header.Add("content-type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", "", err
	}
	defer res.Body.Close()

	var notionRes notionResponse
	if err := json.NewDecoder(res.Body).Decode(&notionRes); err != nil {
		return "", "", err
	}

	if len(notionRes.Results) == 0 {
		return "", "", fmt.Errorf("no page found in Notion database")
	}

	url := notionRes.Results[0].URL

	return notionRes.Results[0].ID, url, nil
}
     (string, string, error) 

では返り値が3つあることを示しています。今回はページIDとURL、取得できなかった時にエラーを返すようにしています。

	dbUrl := "https://api.notion.com/v1/databases/" + n.DatabaseURL + "/query"

ここではアジェンダが生成されるデータベースにアクセスするためのURLを作成しています。

    payload := strings.NewReader(`{
    "filter": {
        "property": "会議種別",
        "multi_select": {
            "contains": "定例"
        }
    },
    "page_size": 1
}`)

ここではアジェンダが生成されるデータベースにアクセスするためのパラメータを作成しています。
会議種別というPropertyに定例という文字列が含まれているもので最新のものを取得するようにしています。
(プロパティの種類はマルチセレクトというものになっています)
notion_multi_select.png

req, err := http.NewRequest("POST", dbUrl, payload)
	if err != nil {
		return "", "", err
	}

	req.Header.Add("accept", "application/json")
	req.Header.Add("Authorization", "Bearer "+n.APIKey)
	req.Header.Add("Notion-Version", "2022-06-28")
	req.Header.Add("content-type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", "", err
	}
	defer res.Body.Close()

リクエストヘッダーの設定をしています。エラーの場合はエラーを返すようにしています。

    var notionRes notionResponse
	if err := json.NewDecoder(res.Body).Decode(&notionRes); err != nil {
		return "", "", err
	}

	if len(notionRes.Results) == 0 {
		return "", "", fmt.Errorf("no page found in Notion database")
	}

	url := notionRes.Results[0].URL

	return notionRes.Results[0].ID, url, nil

リクエストした結果をデコードして、ページIDとURLを返すようにしています。

次にページのタイトルを変更します。


//notion_page_title_patch.go

func (n *NotionAPI) PatchPageTitle(id string) error {
	url := "https://api.notion.com/v1/pages/" + id

	nextThursday := time.Now().AddDate(0, 0, (daysUntilNextThursday-int(time.Now().Weekday()))%7)
	nextThursdayStartStr := nextThursday.Format("2006-01-02")
	nextThursdayTitleStr := nextThursday.Format("01/02")

	payload := strings.NewReader(fmt.Sprintf(`{
    "properties": {
        "名前": {
            "title": [
                {
                    "text": {
                        "content": "定例%s"
                    }
                }
            ]
        },
		"会議開催日": {
            "date": {
   				 "start": "%s",
   				 "end": null
  			}
        }
    }
}`, nextThursdayTitleStr, nextThursdayStartStr))

	req, err := http.NewRequest("PATCH", url, payload)
	if err != nil {
		return err
	}

	req.Header.Add("accept", "application/json")
	req.Header.Add("Authorization", "Bearer "+n.APIKey)
	req.Header.Add("Notion-Version", "2022-06-28")
	req.Header.Add("content-type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}

	defer res.Body.Close()
	body, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return err
	}

	fmt.Println(string(body))
	return nil
}
func (n *NotionAPI) PatchPageTitle(id string) error {

ここでは引数にページIDを取り、エラーがあればエラーを返すようにしています。

    url := "https://api.notion.com/v1/pages/" + id

ここではページのタイトルを変更するためのURLを作成しています。

    nextThursday := time.Now().AddDate(0, 0, (daysUntilNextThursday-int(time.Now().Weekday()))%7)
    nextThursdayStartStr := nextThursday.Format("2006-01-02")
    nextThursdayTitleStr := nextThursday.Format("01/02")

ここでは次回の定例会の日付を取得しています。今回は次回の定例会が木曜日であることを前提にしています。

    payload := strings.NewReader(fmt.Sprintf(`{
    "properties": {
        "名前": {
            "title": [
                {
                    "text": {
                        "content": "定例%s"
                    }
                }
            ]
        },
        "会議開催日": {
            "date": {
   				 "start": "%s",
   				 "end": null
  			}
        }
    }
}`, nextThursdayTitleStr, nextThursdayStartStr)) //%sにはnextThursdayTitleStrとnextThursdayStartStrが順番に入っていきます

ここではページのタイトルを変更するためのパラメータを作成しています。

    req, err := http.NewRequest("PATCH", url, payload) //PATCHメソッドでタイトルを変更するリクエストを作成しています。
    if err != nil {
        return err
    }

    req.Header.Add("accept", "application/json")
    req.Header.Add("Authorization", "Bearer "+n.APIKey)
    req.Header.Add("Notion-Version", "2022-06-28")
    req.Header.Add("content-type", "application/json")

    res, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }

    defer res.Body.Close()
    body, err := ioutil.ReadAll(res.Body)
    if err != nil {
        return err
    }

    fmt.Println(string(body))
    return nil

リクエストヘッダーの設定をしています。エラーの場合はエラーを返すようにしています。

DiscordWebhook

DiscordWebhookの構造体を作成します。

type DiscordWebhook struct {
	UserName  string         `json:"username"`
	AvatarURL string         `json:"avatar_url"`
	Content   string         `json:"content"`
	Embeds    []DiscordEmbed `json:"embeds"`
	TTS       bool           `json:"tts"`
}

type DiscordImg struct {
	URL string `json:"url"`
	H   int    `json:"height"`
	W   int    `json:"width"`
}

type DiscordAuthor struct {
	Name string `json:"name"`
	URL  string `json:"url"`
	Icon string `json:"icon_url"`
}

type DiscordField struct {
	Name   string `json:"name"`
	Value  string `json:"value"`
	Inline bool   `json:"inline"`
}

type DiscordEmbed struct {
	Title  string         `json:"title"`
	Desc   string         `json:"description"`
	URL    string         `json:"url"`
	Color  int            `json:"color"`
	Image  DiscordImg     `json:"image"`
	Thum   DiscordImg     `json:"thumbnail"`
	Author DiscordAuthor  `json:"author"`
	Fields []DiscordField `json:"fields"`
}

DiscordWebhook 構造体は、DiscordのWebhookに送信する情報を表します。この構造体には、送信者の名前 (UserName)、アバター画像のURL (AvatarURL)、送信するメッセージ本文 (Content)、埋め込み情報 (Embeds)、およびテキスト読み上げ機能 (TTS) を持っています。

DiscordImg 構造体は、Discordの埋め込みに含める画像のURL (URL) およびその高さ (H) と幅 (W) を設定できるようになっています。

DiscordAuthor 構造体は、Discordの埋め込みに含める投稿者の情報を表します。この構造体には、投稿者の名前 (Name)、プロフィールページのURL (URL)、およびアイコン画像のURL (Icon) を持っています。

DiscordField 構造体は、Discordの埋め込みに含めるフィールドの情報を表します。この構造体には、フィールドのタイトル (Name)、値 (Value)、および表示方法 (Inline) を持っています。

DiscordEmbed 構造体は、Discordの埋め込み情報を表します。この構造体には、タイトル (Title)、説明文 (Desc)、リンクURL (URL)、色 (Color)、画像 (Image)、サムネイル (Thum)、投稿者情報 (Author)、およびフィールド情報 (Fields) を含みます。Embeds フィールドには、複数の DiscordEmbed 構造体を格納できます。

DiscordWebhookのメソッドを作成します。

func NewDiscordWebhook(userName, avatarURL, content string, embeds []DiscordEmbed, tts bool) *DiscordWebhook {
	return &DiscordWebhook{
		UserName:  userName,
		AvatarURL: avatarURL,
		Content:   content,
		Embeds:    embeds,
		TTS:       tts,
	}
}

func (dw *DiscordWebhook) AddEmbeds(embeds ...DiscordEmbed) {
	dw.Embeds = append(dw.Embeds, embeds...)
}

func (dw *DiscordWebhook) SendWebhook(whURL string) error {
	j, err := json.Marshal(dw)
	if err != nil {
		return fmt.Errorf("json err: %s", err.Error())
	}

	req, err := http.NewRequest("POST", whURL, bytes.NewBuffer(j))
	if err != nil {
		return fmt.Errorf("new request err: %s", err.Error())
	}
	req.Header.Set("Content-Type", "application/json")

	client := http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("client err: %s", err.Error())
	}
	defer resp.Body.Close()

	if resp.StatusCode == 204 {
		fmt.Println("sent", dw) //成功
	} else {
		return fmt.Errorf("%#v\n", resp) //失敗
	}

	return nil
}
func NewDiscordWebhook(userName, avatarURL, content string, embeds []DiscordEmbed, tts bool) *DiscordWebhook {
    return &DiscordWebhook{
        UserName:  userName,
        AvatarURL: avatarURL,
        Content:   content,
        Embeds:    embeds,
        TTS:       tts,
    }
}

DiscordWebhook 構造体を生成するメソッドです。引数には、送信者の名前 (userName)、アバター画像のURL (avatarURL)、送信するメッセージ本文 (content)、埋め込み情報 (embeds)、およびテキスト読み上げ機能 (tts) を指定します。embeds には、複数の DiscordEmbed 構造体を指定します。このメソッドは、DiscordWebhook 構造体のポインタを返します。

func (dw *DiscordWebhook) AddEmbeds(embeds ...DiscordEmbed) {
    dw.Embeds = append(dw.Embeds, embeds...)
}

DiscordWebhook 構造体に埋め込み情報を追加するメソッドです。引数には、複数の DiscordEmbed 構造体を指定できます。
()の中に...をつけることで、複数の引数を受け取ることができます。

func (dw *DiscordWebhook) SendWebhook(whURL string) error {
    j, err := json.Marshal(dw)
    if err != nil {
        return fmt.Errorf("json err: %s", err.Error())
    }

    req, err := http.NewRequest("POST", whURL, bytes.NewBuffer(j))
    if err != nil {
        return fmt.Errorf("new request err: %s", err.Error())
    }
    req.Header.Set("Content-Type", "application/json")

    client := http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("client err: %s", err.Error())
    }
    defer resp.Body.Close()

    if resp.StatusCode == 204 {
        fmt.Println("sent", dw) //成功
    } else {
        return fmt.Errorf("%#v\n", resp) //失敗
    }

    return nil
}

DiscordWebhook 構造体に格納されている情報をDiscordのWebhookに送信するメソッドです。引数には、DiscordのWebhookのURLを指定します。エラーの場合はエラーを返すようにしています。

main.go(エントリーポイント)

func main() {
	notionAPI := &NotionAPI{
		DatabaseURL: loadEnv("NOTION_DATABASE_URL"),
		APIKey:      loadEnv("MIDRA_LAB_NOTION_API"),
	}

	pageID, pageURL, err := notionAPI.ReadPageID()
	if err != nil {
		log.Fatal(err)
	}

	if err := notionAPI.PatchPageTitle(pageID); err != nil {
		log.Fatalf("failed to patch page title: %v", err)
	}

	dw := NewDiscordWebhook("NotificationMTG", "https://source.unsplash.com/random", "@here 定例ドキュメントの更新お願いします!!"+pageURL, nil, false)

	discordWebhookUrl := loadEnv("DISCORD_WEBHOOK_URL")

	if err := dw.SendWebhook(discordWebhookUrl); err != nil {
		log.Fatal(err)
	}
}

func loadEnv(keyName string) string {
	err := godotenv.Load(".env")
	// もし err がnilではないなら、"読み込み出来ませんでした"が出力されます。
	if err != nil {
		fmt.Printf("読み込み出来ませんでした: %v", err)
	}
	// .envの SAMPLE_MESSAGEを取得して、messageに代入します。
	message := os.Getenv(keyName)

	return message
}
notionAPI := &NotionAPI{
		DatabaseURL: loadEnv("NOTION_DATABASE_URL"),
		APIKey:      loadEnv("MIDRA_LAB_NOTION_API"),
	}

NotionAPI 構造体を生成します。引数には、NotionのデータベースのURL (DatabaseURL) と、NotionのAPIキー (APIKey) を指定します。

pageID, pageURL, err := notionAPI.ReadPageID()
    if err != nil {
        log.Fatal(err)
    }

NotionAPI 構造体の ReadPageID メソッドを呼び出して、ページIDとページURLを取得します。エラーの場合は、ログに出力して終了します。

if err := notionAPI.PatchPageTitle(pageID); err != nil {
        log.Fatalf("failed to patch page title: %v", err)
    }

NotionAPI 構造体の PatchPageTitle メソッドを呼び出して、ページのタイトルを更新します。エラーの場合は、ログに出力して終了します。

dw := NewDiscordWebhook("NotificationMTG", "https://source.unsplash.com/random", "@here 定例ドキュメントの更新お願いします!!"+pageURL, nil, false)

DiscordWebhook 構造体を生成します。引数には、送信者の名前 (userName)、アバター画像のURL (avatarURL)、送信するメッセージ本文 (content)、埋め込み情報 (embeds)、およびテキスト読み上げ機能 (tts) を指定します。embeds には、複数の DiscordEmbed 構造体を指定します。このメソッドは、DiscordWebhook 構造体のポインタを返します。

discordWebhookUrl := loadEnv("DISCORD_WEBHOOK_URL")

    if err := dw.SendWebhook(discordWebhookUrl); err != nil {
        log.Fatal(err)
    }

DiscordWebhook 構造体の SendWebhook メソッドを呼び出して、DiscordのWebhookに送信します。エラーの場合は、ログに出力して終了します。

Github Actions

name: notification-scheduled
on:
  schedule:
    - cron: '0 12 * * thu' # 日本時間の21時に実行
  push:
    branches: [ main ]
  workflow_dispatch:
jobs:
  build:
    runs-on: ubuntu-latest
    env:
      MIDRA_LAB_NOTION_API: ${{ secrets.MIDRA_LAB_NOTION_API }}
      NOTION_DATABASE_URL: ${{ secrets.NOTION_DATABASE_URL }}
      DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
    steps:
      - uses: actions/checkout@v3.3.0
      - name: create env file
        run: |
          touch .env
          echo "MIDRA_LAB_NOTION_API=${MIDRA_LAB_NOTION_API}" >> .env
          echo "NOTION_DATABASE_URL=${NOTION_DATABASE_URL}" >> .env
          echo "DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL}" >> .env
      - name: Build
        run: |
          docker-compose up -d --build
      - name: Run
        run: |
          docker-compose run mtg-notification go run main.go discord_messenger.go notion_page_title_patch.go

このGitHub Actionsワークフローは、以下の順序でタスクを実行します。

  1. トリガー:このワークフローは、スケジュール、プッシュイベント、および手動実行によってトリガーされます。スケジュールは毎週木曜日の日本時間21時に設定されています。また、mainブランチへのプッシュイベントでも実行されます。

  2. 実行環境:ワークフローは、最新のUbuntuイメージ上で実行されます。

  3. 環境変数:MIDRA_LAB_NOTION_API、NOTION_DATABASE_URL、およびDISCORD_WEBHOOK_URLの3つの環境変数が、GitHub Secretsから取得されます。

  4. リポジトリのチェックアウト:actions/checkout@v3.3.0を使用して、リポジトリがチェックアウトされます。

  5. 環境変数ファイルの作成:.envファイルが作成され、先ほど取得した環境変数が追記されます。

  6. Dockerコンテナのビルド:docker-compose up -d --buildコマンドを実行して、Dockerコンテナがビルドされ、実行されます。

  7. アプリケーションの実行:最後に、docker-compose runコマンドを使って、mtg-notificationサービスを実行し、main.go、discord_messenger.go、およびnotion_page_title_patch.goを起動します。

Dockerfile

FROM golang:1.19.5-alpine

# アップデートとgitのインストール
RUN apk update && apk add git
RUN apk add bash

# appディレクトリの作成
RUN mkdir /go/src/app

# ワーキングディレクトリの設定
WORKDIR /go/src/app

# Goモジュールの初期化
RUN go mod init example.com/mtg-notification

# godotenvライブラリのインストール
RUN go get github.com/joho/godotenv

# .envファイルのコピー
COPY .env /go/src/app

# ホストのファイルをコンテナの作業ディレクトリに移行
COPY . /go/src/app

compose.yaml

version: "3"
services:
    mtg-notification:
        container_name: mtg-notification
        build: .
        tty: true
        environment:
            - MIDRA_LAB_NOTION_API=${MIDRA_LAB_NOTION_API}
            - NOTION_DATABASE_URL=${NOTION_DATABASE_URL}
            - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL}
        env_file:
            - .env
        volumes:
            - ./app:/go/src/app

このcompose.ymlファイルは、mtg-notificationという名前のDockerサービスを定義しています。以下は、その構成内容について順序立てて説明したものです。

  1. コンテナ名:container_nameにより、コンテナ名がmtg-notificationとして設定されています。

  2. ビルドコンテキスト:ビルドコンテキストは、カレントディレクトリ(.)に設定されています。これは、Dockerfileが同じディレクトリに存在することを意味します。

  3. TTY:ttyがtrueに設定されているため、コンテナは対話型の端末デバイスとして動作します。

  4. 環境変数:MIDRA_LAB_NOTION_API、NOTION_DATABASE_URL、およびDISCORD_WEBHOOK_URLの3つの環境変数が設定されています。これらの変数は、それぞれ.envファイルから読み込まれます。

  5. 環境変数ファイル:env_fileディレクティブを使用して、.envファイルがコンテナにロードされます。このファイルには、先述の環境変数が定義されています。

  6. ボリューム:./appディレクトリ(ホストマシン上)がコンテナ内の/go/src/appディレクトリにマウントされます。これにより、アプリケーションのコードがコンテナ内で利用可能になり、

まとめ

今回は、Go言語を用いて、Discordに通知を送信するアプリケーションを作成しました。また、GitHub Actionsを用いて、定期的にDiscordに通知を送信するようにしました。これにより、毎週木曜日の日本時間21時に、Discordに通知が送信されるようになりました。今回の記事では、Go言語の基本的な文法や、Dockerの基本的な使い方について説明しました。また、GitHub Actionsの基本的な使い方についても説明しました。今回の記事を読んで、Go言語やDocker、GitHub Actionsについて少しでも理解が深まった方がいれば幸いです。
僕自身Dockerの完全初心者だったため、この部分は@ayousanzに大きく助けてもらいました。ありがとうございました!

MidraLabでは様々な運営機能を自動かさせていくという目標があります。何かあると嬉しいものがあればコメントにアイディアを書いていただけると幸いです!

MidraLab(ミドラボ)

Discussion