Discordに投稿された画像をGooglePhotoに保存するBotを作る

# はじめに
初投稿です。もし何か気づきやご意見がありましたら、ぜひフィードバックをお願いします。

Lineだけでの家族内コミュニケーションに限界を感じています。
Slackの無料版は[90日しか履歴が残らない](https://app.slack.com/plans/T05LEQ4J86R)ので、Discordが第一候補に挙がりました。
Discordを理解する手段の1つとしてBot作成にトライしました。
個人の備忘録も兼ねて手順を投稿します。

# 1.Photo LibraryAPIの有効化
以下の解説記事の[Photo Library APIの有効化]セクションを参照してください。
[参考：PythonからGooglePhotoに画像や動画をアップロード](https://qiita.com/sey323/items/4c7140efaed750e690eb)

# 2. 認証情報の作成
以下の解説記事の[認証情報の作成]セクションを参照してください。
取得した認証情報のjsonファイルは後ほど使用します。
[参考：PythonからGoogleDriveにファイルをアップロード](https://qiita.com/sey323/items/875c0ab1585044772ab2#%E8%AA%8D%E8%A8%BC%E6%83%85%E5%A0%B1%E3%81%AE%E4%BD%9C%E6%88%90
)

# 3. DiscordBotのアカウント作成
1. 以下のURLにアクセスします。
[Developer portal](https://discord.com/developers/applications)
2. [NewApplication]ボタンをクリックします。
3. アプリケーション名を入力しチェックボックスにチェックを入れたら[Create]ボタンをクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3535581/d8db7c65-a02f-8bbb-b81d-40155bf7dffd.png)
4. 作成したBotの設定画面に遷移します。必要に応じてICON、NAMEなどを任意に設定してください。
5. 左のメニューから[Bot]を選び、[MESSAGE CONTENT INTENT]にチェックを入れます。
これによってBotがDiscordで投稿された画像にアクセスできるようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3535581/f62832fa-90b2-a88c-d104-d79b5cb8ffb9.png)
6. [Bot]画面上の[ResetToken]をクリックしてTokenを取得します。このTokenは後で使うのでメモしておいてください。 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3535581/5ab3c764-5b3f-182a-a48e-1eca36530db1.png) 
7. 左のメニューから[URL Generator]を選び、[Bot]にチェックを入れます。
するとBotに与える権限を選択するチェックボックスが表示されますので、適切な権限を選択します。（今回は[Administrator]を選択しました。）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3535581/bca2db4d-afb8-e86a-4e75-57e6f2ca8e79.png)
8. さきほどのチェックボックス下部にある[GENERATED URL]欄のURLにアクセスします。Botを追加するサーバを選択したら[はい]をクリックします。他に指示が続くようであれば、画面の指示に従ってください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3535581/e4285754-e205-a5e0-01cd-c3062302e28a.png)
以上でDiscordサーバにBotを作成できます。

# 4. 必要ライブラリのインストール
以下のライブラリをインストールしてください。
* discord.py
* google-api-python-client
* google-auth-oauthlib

# 5. 実装その1
GooglePhotoに画像を保存するクラス`GooglePhotoFacade.py`を作成します。なお、このコードも先ほどと同じ記事からの引用です。
[参考：PythonからGooglePhotoに画像や動画をアップロード](https://qiita.com/sey323/items/4c7140efaed750e690eb)
```python
import pickle
from pathlib import Path
import requests
from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build

# 各URLやスコープ
API_SERVICE_NAME = "photoslibrary"
API_VERSION = "v1"
SCOPES = ["https://www.googleapis.com/auth/photoslibrary.appendonly"]


class GooglePhotoFacade:
    # ログインしてセッションオブジェクトを返す
    def __init__(
        self,
        credential_path: str,
        token_path: str = "",
    ):
        with build(
            API_SERVICE_NAME,
            API_VERSION,
            credentials=self._login(credential_path, token_path),
            static_discovery=False,
        ) as service:
            self.service = service
            print("Google OAuth is Complete.")

        self.credential_path = credential_path
        self.token_path = token_path

    def _login(self, credential_path: str, token_path: str) -> any:
        """Googleの認証を行う

        Args:
            credential_path (str): GCPから取得したclient_secret.jsonのパス
            token_path (str): Oauth2認証によって得られたトークンを保存するパス。

        Returns:
            googleapiclient.discovery.Resource: _description_
        """

        if Path(token_path).exists():
            # TOKENファイルを読み込み
            with open(token_path, "rb") as token:
                credential = pickle.load(token)
            if credential.valid:
                print("トークンが有効です.")
                return credential
            if credential and credential.expired and credential.refresh_token:
                print("トークンの期限切れのため、リフレッシュします.")
                # TOKENをリフレッシュ
                credential.refresh(Request())
        else:
            print("トークンが存在しないため、作成します.")
            credential = InstalledAppFlow.from_client_secrets_file(
                credential_path, SCOPES
            ).run_local_server()

        # CredentialをTOKENファイルとして保存
        with open(token_path, "wb") as token:
            pickle.dump(credential, token)

        return credential

    def upload(
        self,
        local_file_path: str,
    ):
        self._login(self.credential_path, self.token_path)  # トークンの期限を確認

        save_file_name: str = Path(local_file_path).name
        with open(str(local_file_path), "rb") as image_data:
            url = "https://photoslibrary.googleapis.com/v1/uploads"
            headers = {
                "Authorization": "Bearer " + self.service._http.credentials.token,
                "Content-Type": "application/octet-stream",
                "X-Goog-Upload-File-Name": save_file_name.encode(),
                "X-Goog-Upload-Protocol": "raw",
            }
            response = requests.post(url, data=image_data.raw, headers=headers)

        upload_token = response.content.decode("utf-8")
        print("Google Photoへのアップロードが完了しました。")
        body = {"newMediaItems": [{"simpleMediaItem": {"uploadToken": upload_token}}]}

        upload_response = self.service.mediaItems().batchCreate(body=body).execute()
        print("Google Photoへのアップロードした動画の登録に成功しました。")

        # uploadしたURLを返す
        return upload_response["newMediaItemResults"][0]["mediaItem"]

```
# 6.実装その2
Discord側の処理を`main.py`として実装します。
* `client_secrets.json`は[手順2](#2.認証情報の作成)で取得したjsonファイルです。同じファイル名で`main.py`と同じライブラリに置けばこのままでも動作します。
* `YOUR_TOKEN`の部分は[手順3](#3.DiscordBotのアカウント作成)で取得したTOKENに置き換えてください。
* `YOUR_CHANNEL_ID`の部分はBotを追加したいチャンネルのIDで置き換えてください。チャンネルのIDはURLから確認できます。
`https://discord.com/channels/サーバーのID/チャンネルのID`

```python
import discord
import tempfile
from GooglePhotoFacade import GooglePhotoFacade

# ボットのトークン
TOKEN = "YOUR_TOKEN"

# Discord Intentsの設定
intents = discord.Intents.default()
intents.message_content = True

# Google Photoへのアップロードに使用するクラス
google_photo = GooglePhotoFacade(
    credential_path="client_secrets.json", token_path="token.pkl"
)

# Discordクライアントを作成
client = discord.Client(intents=intents)


@client.event
async def on_ready():
    print(f"Logged in as {client.user.name}")


@client.event
async def on_message(message):
    # メッセージがボット自身のものであれば無視
    if message.author == client.user:
        return
    # 特定のチャンネルID
    target_channel_id = YOUR_CHANNEL_ID

    # 投稿されたメッセージが特定のチャンネルからのものかチェック
    if message.channel.id == target_channel_id:
        # 画像が添付されているかチェック
        if len(message.attachments) > 0:
            total_images = len(message.attachments)
            for index, attachment in enumerate(message.attachments, start=1):
                if attachment.content_type.startswith("image"):
                    # 画像のダウンロード
                    image_data = await attachment.read()
                    # 一時ファイルとして保存
                    with tempfile.NamedTemporaryFile(delete=False) as temp_file:
                        temp_file.write(image_data)
                    save_file_path = temp_file.name
                    # 画像をGoogle Photoにアップロード
                    uploaded_media = google_photo.upload(local_file_path=save_file_path)

                    # アップロードの進捗と画像のURLを表示
                    upload_message = f"{total_images}枚中の{index}枚目の画像をアップロードが完了しました。\n{uploaded_media['productUrl']}"
                    await message.reply(upload_message)


if __name__ == "__main__":
    # ボットを起動
    client.run(TOKEN)
```

# 7.動作確認
`main.py`を起動させた状態でDiscordに画像を投稿するとBotが動作します。無事アップロードが完了するとBotから画像のような返信がきます。同時に複数枚投稿しても問題ありません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3535581/24caaa4d-4b00-e3b1-0257-8e49147700da.png)
GooglePhotoを開くと無事に画像が保存されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3535581/ae773e21-1b74-9431-e315-908dd0d87804.png)

# 参考サイト
文中に載せた参考サイトを改めて列記します。ありがとうございます。
* [参考：PythonからGooglePhotoに画像や動画をアップロード](https://qiita.com/sey323/items/4c7140efaed750e690eb)
* [参考：PythonからGoogleDriveにファイルをアップロード](https://qiita.com/sey323/items/875c0ab1585044772ab2#%E8%AA%8D%E8%A8%BC%E6%83%85%E5%A0%B1%E3%81%AE%E4%BD%9C%E6%88%90
)