【Firebase + QiitaAPI + discord.py 】追加したメンバーのQiitaの記事を通知してくれるbot作ってみた

# はじめに

こんにちは！今回は、**Firebase**と**Qiita API**、そして**discord.py**を組み合わせて、指定したメンバーのQiita記事をDiscord上で通知してくれるBotを作成してみました。

このBotは以下の機能を持っています：

- メンバーの追加・削除
- 登録されたメンバー一覧の表示
- 今日投稿された記事の通知
- 今週投稿された記事の通知

それでは、実際のコードとともに作り方を解説していきます。

## 環境設定

### 必要なライブラリのインストール

まずは、必要なライブラリをインストールします。

```bash
pip3 install discord.py firebase-admin python-dotenv requests
```

### Firebaseの設定

1. **Firebaseプロジェクトの作成**

   Firebase Consoleで新しいプロジェクトを作成します。

2. **サービスアカウントの作成**

   プロジェクトの設定からサービスアカウントを選択し、新しい秘密鍵を生成してダウンロードします。このファイルをプロジェクトディレクトリに`firebase.json`として保存します。

3. **Firestoreの有効化**

   データベースとしてFirestoreを使用します。Firestoreを有効化し、セキュリティルールを適切に設定します。

### Discord Botの作成

1. **Discord Developer Portalでアプリケーションを作成**

   Discord Developer Portalで新しいアプリケーションを作成します。

2. **Botの追加**

   アプリケーションにBotを追加し、Botのトークンを取得します。このトークンは後で使用します。

3. **必要なIntentの有効化**

   Botの設定で「MESSAGE CONTENT INTENT」を有効にします。

4. **Botをサーバーに追加**

   OAuth2 URL Generatorを使用して、Botを自分のDiscordサーバーに招待します。

### `.env` ファイルの作成

プロジェクトディレクトリに`.env`ファイルを作成し、Discord Botのトークンを設定します。

```env
DISCORD_BOT_TOKEN=your_discord_bot_token_here
```

## コードの解説

では、実際のコードを見ていきましょう。

```python
import os
import discord
from discord.ext import commands
from dotenv import load_dotenv
import firebase_admin
from firebase_admin import credentials, firestore
import asyncio
import requests
from typing import List, Dict, Any
from datetime import date, timedelta

# .env ファイルから環境変数を読み込む
load_dotenv()

# Discord Botの設定
intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix='!', intents=intents)

# Firebaseの初期化
cred = credentials.Certificate("./firebase.json")
firebase_admin.initialize_app(cred)
db = firestore.client()

# Firestoreから登録された名前を取得
async def get_names_from_firestore() -> List[str]:
    names_ref = db.collection('names')
    docs = names_ref.stream()
    return [doc.to_dict()['name'] for doc in docs]

# 登録されている名前一覧を表示
async def display_names_list(ctx: commands.Context) -> None:
    try:
        names = await get_names_from_firestore()
        if names:
            names.sort()
            names_list = "\n".join(names)
            await ctx.send(f"登録されている名前一覧:\n```\n{names_list}\n```")
        else:
            await ctx.send("登録されている名前はありません。")
    except Exception as e:
        print(f"Error in displaying names: {str(e)}")
        await ctx.send(f"名前の表示中にエラーが発生しました: {str(e)}")

# Qiita APIを使用して記事を取得
def get_articles(userid: str) -> List[Dict[str, Any]]:
    url = f'https://qiita.com/api/v2/users/{userid}/items'
    try:
        response = requests.get(url)
        response.raise_for_status()
        return response.json()
    except requests.RequestException as e:
        print(f"APIリクエストエラー: {e}")
        return []

# 名前の入力を処理
async def handle_name_input(ctx: commands.Context, action: str) -> str:
    await ctx.send(f"{action}したい名前を入力してください")
    def check(m):
        return m.author == ctx.author and m.channel == ctx.channel
    try:
        user_message = await bot.wait_for('message', check=check, timeout=30.0)
        return user_message.content.strip()
    except asyncio.TimeoutError:
        await ctx.send("タイムアウトしました。もう一度お試しください。")
        return ""

# 名前を追加するコマンド
@bot.command(name='add')
async def add_name(ctx: commands.Context) -> None:
    await display_names_list(ctx)
    name = await handle_name_input(ctx, "追加")
    if not name:
        return
    try:
        names = await get_names_from_firestore()
        if name not in names:
            db.collection('names').add({'name': name})
            await ctx.send(f'{name} を追加しました')
        else:
            await ctx.send(f'{name} は既に登録されています')
    except Exception as e:
        print(f"Error in add command: {str(e)}")
        await ctx.send(f"エラーが発生しました: {str(e)}")

# 登録されている名前一覧を表示するコマンド
@bot.command(name='ls')
async def display_names(ctx: commands.Context) -> None:
    await display_names_list(ctx)

# 名前を削除するコマンド
@bot.command(name='rm')
async def delete_name(ctx: commands.Context) -> None:
    await display_names_list(ctx)
    name = await handle_name_input(ctx, "削除")
    if not name:
        return
    try:
        names = await get_names_from_firestore()
        if name in names:
            names_ref = db.collection('names')
            query = names_ref.where('name', '==', name)
            docs = query.stream()
            for doc in docs:
                doc.reference.delete()
            await ctx.send(f'{name} を削除しました')
        else:
            await ctx.send(f'{name} は登録されていません')
    except Exception as e:
        print(f"Error in delete command: {str(e)}")
        await ctx.send(f"エラーが発生しました: {str(e)}")

# 今日の記事を通知するコマンド
@bot.command(name='daily')
async def daily(ctx: commands.Context) -> None:
    members = await get_names_from_firestore()
    for member in members:
        articles = get_articles(member)
        if not articles:
            await ctx.send(f"{member}の記事が見つかりませんでした。")
            continue

        today_articles = [
            article for article in articles
            if article['created_at'].split('T')[0] == str(date.today())
        ]

        if not today_articles:
            await ctx.send(f"{member}の今日の記事は見つかりませんでした。")
        else:
            await ctx.send(f"**{member}** の今日のQiita記事:")
            await ctx.send("--------------------")
            for article in today_articles:
                await ctx.send(
                    f"タイトル: {article['title']}\n"
                    f"URL: {article['url']}\n"
                    f"投稿日: {article['created_at']}\n"
                    f"いいね数: {article['likes_count']}\n"
                    "---"
                )
            await ctx.send("--------------------")

# 今週の記事を通知するコマンド
@bot.command(name='weekly')
async def weekly(ctx: commands.Context) -> None:
    members = await get_names_from_firestore()
    for member in members:
        articles = get_articles(member)
        if not articles:
            await ctx.send(f"{member}の記事が見つかりませんでした。")
            continue
        today = date.today()
        monday = today - timedelta(days=today.weekday())
        weekly_articles = [
            article for article in articles
            if article['created_at'].split('T')[0] >= str(monday)
        ]
        if not weekly_articles:
            await ctx.send(f"{member}の今週の記事は見つかりませんでした。")
        else:
            await ctx.send(f"**{member}** の今週のQiita記事:")
            await ctx.send("--------------------")
            for article in weekly_articles:
                await ctx.send(
                    f"タイトル: {article['title']}\n"
                    f"URL: {article['url']}\n"
                    f"投稿日: {article['created_at']}\n"
                    f"いいね数: {article['likes_count']}\n"
                    "--------------------"
                )
            await ctx.send("--------------------")

# ヘルプコマンド
@bot.command(name='h')
async def help(ctx: commands.Context) -> None:
    help_message = """
このBotの使い方:
- `!add`: 名前を追加します
- `!ls`: 登録されている名前一覧を表示します
- `!rm`: 名前を削除します
- `!daily`: 登録されている名前の今日のQiita記事を表示します
- `!weekly`: 登録されている名前の今週のQiita記事を表示します
"""
    await ctx.send(help_message)

# 起動時に実行される処理
@bot.event
async def on_ready():
    print(f'{bot.user} としてログインしました')

# Botの起動
bot.run(os.getenv('DISCORD_BOT_TOKEN'))
```

## コードのポイント

### 1. Discord Botの初期化

```python
intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix='!', intents=intents)
```

- `intents.message_content = True`でメッセージコンテンツの取得を有効化します。
- `command_prefix='!'`でコマンドのプレフィックスを設定しています。

### 2. Firebaseの初期化

```python
cred = credentials.Certificate("./firebase.json")
firebase_admin.initialize_app(cred)
db = firestore.client()
```

- ダウンロードした`firebase.json`を使用してFirebaseを初期化します。
- Firestoreのクライアントを取得しています。

### 3. メンバーの管理

- **追加（!add）**：Firestoreの`names`コレクションに新しいドキュメントを追加します。
- **一覧表示（!ls）**：Firestoreから全ての名前を取得し、表示します。
- **削除（!rm）**：指定した名前のドキュメントをFirestoreから削除します。

### 4. Qiita APIの使用

```python
def get_articles(userid: str) -> List[Dict[str, Any]]:
    url = f'https://qiita.com/api/v2/users/{userid}/items'
    try:
        response = requests.get(url)
        response.raise_for_status()
        return response.json()
    except requests.RequestException as e:
        print(f"APIリクエストエラー: {e}")
        return []
```

- ユーザーIDを使用して、そのユーザーの公開記事を取得します。
- エラー処理も含めています。

### 5. 日付によるフィルタリング

- **今日の記事（!daily）**：`created_at`の日付が今日の日付と一致する記事をフィルタリングします。
- **今週の記事（!weekly）**：今週の月曜日以降の記事をフィルタリングします。

### 6. コマンドの実装

各コマンドは`@bot.command`デコレータを使用して実装されています。

- `!add`：メンバーの追加
- `!ls`：メンバー一覧の表示
- `!rm`：メンバーの削除
- `!daily`：今日の記事の表示
- `!weekly`：今週の記事の表示
- `!h`：ヘルプの表示

## 動作確認

Botを起動し、Discordサーバー上で以下のコマンドを試してみてください。

1. **メンバーの追加**

   ```
   !add
   ```

   Botが名前の入力を促してくるので、QiitaのユーザーIDを入力します。

2. **メンバー一覧の表示**

   ```
   !ls
   ```

   登録されているメンバーの一覧が表示されます。

3. **今日の記事の表示**

   ```
   !daily
   ```

   登録されているメンバーの今日のQiita記事が表示されます。

4. **今週の記事の表示**

   ```
   !weekly
   ```

   登録されているメンバーの今週のQiita記事が表示されます。

5. **ヘルプの表示**

   ```
   !h
   ```

   Botの使い方が表示されます。

## まとめ

今回は、Firebase、Qiita API、discord.pyを使用して、指定したメンバーのQiita記事をDiscord上で通知するBotを作成しました。Firestoreを使用することで、データの永続化が可能になり、Botの再起動後も登録されたメンバー情報を保持できます。