🐣

connpassの勉強会情報をDiscordへ通知する

2024/06/28に公開

はじめに

みなさんは勉強会やイベントはお好きですか?興味のある分野のLTや他社エンジニアの実体験をきくとモチベーションが上がりますし、エンジニア同士のつながりができるのも魅力だと思います👩🏼‍💻
興味のある勉強会やイベントの情報を効率よく取得できるようDiscordを使って情報を得るシステムを構築しました。

つくったもの

Discordにこのような感じでイベントが通知されます。

本当はイベント画像もあるとよりイベントに興味も持ちやすく良かったのですがAPIからは取得できませんでした😮‍💨
画像については引き続き取得方法を検討してみたいと思います。

使用する技術スタック

  • Bun
  • connpass API
  • Discord Webhook
  • (AWS)

通知するメッセージングアプリはできるだけ毎日使うもので通知が目に止まりやすいものが良いと考え、今回はDiscordを選択しました。Discord webhookへの送信は、fetchでも簡単に送信できますが、
Embedを簡単に扱うためにDiscord.jsのパッケージを使用しています。今回はそこまで凝ったことはしていないですがDiscord.jsはドキュメントが豊富なところも好きです!

またランタイムにBunを採用したのは新しくて軽量で色んな機能がついているという情報を知っていたので、どんなところが違うのか体験したいというミーハーな気持ちで選定しました🥰
Connpass APIを利用するためには固定IPが必要ですが自分のネットワークには固定IPがないため、AWS EC2インスタンスをパブリックサブネットに立てて、実行環境として利用することにしました。

(2024年5月23日(木)より 「企業・法人」「コミュニティ及び個人」向けの2プランの提供が開始され、利用申請が必要になりました。個人での利用は無料です。詳細は下記)

https://help.connpass.com/api/

データの取得から通知までの流れ

APIからイベントの情報を取得する

fetch関数を使用してconnpass APIにリクエストを送信しイベント情報を取得します。

今回はコマンドライン引数でキーワードと場所を絞り込み検索できるように、下記のように実行されることを想定します。
bun src/main.ts --keyword javascript,Nest.js --location 東京

src/utils/fetchEvents.ts
interface Series {
  id: number | null;
  title: string | null;
  url: string | null;
}

interface Event {
  event_id: number | null;
  title: string | null;
  catch: string | null;
  description: string | null;
  event_url: string | null;
  hash_tag: string | null;
  started_at: string | null;
  ended_at: string | null;
  limit: number | null;
  event_type: string | null;
  address: string | null;
  place: string | null;
  lat: number | null;
  lon: number | null;
  owner_id: number | null;
  owner_nickname: string | null;
  owner_display_name: string | null;
  accepted: number | null;
  waiting: number | null;
  updated_at: string | null;
  series: Series;
}

interface SearchResult {
  results_returned: number | null;
  results_available: number | null;
  results_start: number | null;
  events: Event[];
}

export async function fetchEvents(
  keywords: Array<string>,
  location: string
): Promise<SearchResult> {
  const ENDPOINT = "https://connpass.com/api/v1";

  const res = await fetch(
    `${ENDPOINT}/event/?keyword_or=${keywords}&keyword=${location}`
  );

  return await res.json();
}

export type { Event, SearchResult, Series };

取得した情報を選別し通知する関数へ渡す

コマンドライン引数からキーワードと場所を取得し、fetchEvents関数でconnpass APIからイベント情報を取得します。

取得したイベント情報をデータベースに保存し、新しいイベントだけを選別してnoticeConnpass関数を使ってDiscordに通知します。
通知済みのイベントは一度通知したら次回からは通知しないようにしたいため通知済みのイベント情報を保存するためにSQLiteを利用しました。

main.ts
import { fetchEvents, type Event } from "./utils/fetchEvents";
import { db } from "./utils/db";
import { noticeConnpass } from "./utils/noticeConnpass";
import { parseArgs, type ParseArgsConfig } from "util";

async function main() {
  const { values } = parseArgs({
    args: Bun.argv,
    options: {
      keyword: {
        type: "string",
      },
      location: {
        type: "string",
      },
    },
    strict: true,
    allowPositionals: true,
  });

  const keywords = values?.keyword?.split(",") ?? [];
  const location = values?.location ?? "";

  const { events } = await fetchEvents(keywords, location);
  const newEvents: Event[] = [];
  for (const event of events) {
    const { changes } = db
      .prepare(
        `INSERT OR IGNORE INTO events (
          event_id, title, catch, description, event_url, hash_tag, started_at, ended_at, event_limit, event_type, 
          series_id, series_title, series_url, address, place, lat, lon, owner_id, owner_nickname, owner_display_name, 
          accepted, waiting, updated_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
      )
      .run(
        event.event_id,
        event.title,
        event.catch,
        event.description,
        event.event_url,
        event.hash_tag,
        event.started_at,
        event.ended_at,
        event.limit,
        event.event_type,
        event.series?.id,
        event.series?.title,
        event.series?.url,
        event.address,
        event.place,
        event.lat,
        event.lon,
        event.owner_id,
        event.owner_nickname,
        event.owner_display_name,
        event.accepted,
        event.waiting,
        event.updated_at
      );
    if (changes > 0) {
      newEvents.push(event);
    }
  }
  if (newEvents.length > 0) {
    await noticeConnpass(newEvents);
  }
}
main();

SQLiteのDDLではevent_idをプライマリーキーとして設定しており、通知済みのイベントは既にテーブル上に保存されているため、INSERT OR IGNOREでインサートが行われなかった場合、重複データとして通知を行わないようにします。
changes変数に変更された行数が返ってきているので、0であれば重複していることになります。
(Bulk Insert等を行った方がパフォーマンスが良いと思いますが今回は割愛します。)
新しいイベントはnewEvents配列に追加し、DiscordのWebhookに送信します。

余談:BunでのDB操作のバグ修正について(バージョン起因)

Bun v1.1.14で開発していたところ、db.run()changesフィールドは変更された行数を返すべきですが、常に0が返ってきていることに気がつきました。

そこで調査したところ、以下のバグが判明しました:

Fixed: bun's changes field always 0 in statement.run()

A bug in prepared statements caused db.prepare(...).run(...) to return 0 in certain cases instead of the number of changes from the statement. db.run(...) was not affected.

このバグは、db.prepare(...).run(...)が特定のケースで変更された行数ではなく常に0を返すものでした。

Bun v1.1.16にアップデートしたところ無事正常な値を受け取ることができました👏🏼

Discord webhookへ送信する処理

Discordクライアントを作成しイベントをまわして送信するだけ、シンプルです。今回はDiscord.js v14を使用しています。

レートリミットや可読性の向上のため、embedを使って1つのメッセージにまとめて送信することにしました。

noticeConnpass.ts
import { EmbedBuilder, WebhookClient } from "discord.js";
import type { Event } from "./fetchEvents";
import dayjs from "dayjs";
import split from "just-split";
import truncate from "just-truncate";
import { stripHtml } from "string-strip-html";

// Discord Webhookの最大埋め込み数
const MAX_EMBEDS = 10;
const MAX_DESCRIPTION_LENGTH = 100;
const url = process.env.WEBHOOK_URL ?? "";

const webhook = new WebhookClient({ url });

export async function noticeConnpass(events: Event[]) {
  try {
    split(events, MAX_EMBEDS).map(async (chunk) => {
      const embeds = chunk.map((event) => {
        const formattedDate = dayjs(event.started_at).format(
          "YYYY/MM/DD HH:mm"
        );

        const eventCatch = event.catch ?? "";
        const eventDescription = event.description ?? "";
        const description = eventCatch.length ? eventCatch : eventDescription;

        const builder = new EmbedBuilder();

        if (description.length) {
          builder.setDescription(
            truncate(stripHtml(description).result, MAX_DESCRIPTION_LENGTH)
          );
        }

        return builder
          .setTitle(event.title)
          .setURL(event.event_url)
          .setFields(
            {
              name: ":date: 日時",
              value: formattedDate,
              inline: true,
            },
            {
              name: ":round_pushpin:場所",
              value:
                event.address && event.address.length > 0
                  ? event.address
                  : "未定",
              inline: true,
            }
          );
      });

      await webhook.send({
        content: ":tada:更新されたイベント:tada:",
        embeds: embeds,
      });
    });
  } catch (error) {
    console.error(error);
  }
}

定期実行

ポータビリティ性の考慮と、Bun v1.1.5で実装されたクロスコンパイル機能を試したいため今回はシングルバイナリを生成してみます。

今回の実行環境は、EC2でt4g.nanoのubuntu環境を用意したため、ビルド時のターゲットは「bun-linux-arm64」を指定します。

今回はGitHub Actions上でクロスコンパイルを行いGitHub ReleasesからバイナリをDLしました。

bun build --compile src/main.ts --target=bun-linux-arm64
 --outfile notify_connpass

※ Bun v1.1.16ではコード上で環境変数を使用する場合、ビルド時の環境変数を展開した状態でバイナリに含めるような挙動をしているようなので、環境変数の扱いにはご注意ください。

cronを使用して、DLしたバイナリを定期的に実行します。

今回使用するWebhook URLをcronの設定ファイル内で一緒に定義します。

/etc/cron.d/notify_connpass
WEBHOOK_URL="WebhookのURL"

# 毎日9時にスクリプトを実行
0 9 * * * ubuntu /usr/local/bin/notify_connpass --keyword QA,Next.js,Vercel --location 東京 >> /var/log/notify_connpass.log 2>&1

これで通知が届くようになりました🎉

まとめ

これまで定期的にconnpassのサイトでイベントを確認していましたが、今回の自動化により気になるイベントの情報が勝手に入ってくるようになりました。自分でイベント情報を探すのも楽しいですが、自動で通知することで見逃しも減りますし、時間の節約にもなります。

興味がある方は、ぜひ試してみてください!

chot Inc. tech blog

Discussion