DiscordとLINEをPython+flask+Dockerで連携してみた。その0 とりあえず運用

**2022/11/24追記**
**10月31日にLINE公式から発表された公式アカウントの料金プラン改定にて、23年6月以降本ボットの運用が困難になります。**
**現在LINE Notify対応用に作りなおしているので、本記事は打ち切りとなります。**
**完成次第記事にいたしますので、今しばらくお待ちください。**

2023/01/19追記
LINE Notify版を記事にしました。

https://qiita.com/maguro-alternative/items/6f57d4cc6c9923ba6a1d

こんにちは。Qiita投稿2回目のマグロです。

皆さん、Discordって使ってますか？
便利ですけどLINEより使う機会がない、、使い方が分からない、、なんて人もいると思います。
実際私の友人たちがそんな感じでした。

そんな時、LINEとDiscordのメッセージをつなげてみようかと思いつきました。
調べてみると記事もあり、実際に運用してみると思っていたのと違う部分が多く、こうなったら自分で改良しよう！！
というわけで作り方を、、、と言いたいのですがあまりにも膨大になりすぎたのでいくつか記事を分けます。

書ききれるかもわからないので初回はとりあえず運用してみます。

すでに構築済みのものを配布しているのでそれの使い方を説明します。

ただやることがめちゃくちゃ多いので、根気と知識が必要です。
あと説明がかなり雑だと思うので、遠慮なく質問してください。可能な限りお答えいたします。

# 偉大な先駆者様との違い

https://qiita.com/i_tatte/items/6cd8d9ce0a93df249937

思いっきり参考(というかパクリ)にしていますが、ほぼ別物といえるほど改良を施してます。
違う点は以下の通り。
## 先駆者様
* 使ったサービス
    - Google App Script
    - Glitch
* 言語
    - JavaScript
* やり取りできる内容
    - テキストメッセージ
* 送信形式とか
    - Discord-WebHook
    - LINE-グループ

GlitchがDiscordでのメッセージを受け取り、Google App ScriptでLINEに送信しています。
Google App ScriptはLINEのメッセージを受け取り、DiscordにWebHookでメッセージを送信してます。
最低限のやり取りは十分にできますが、いくつかの課題があります。

- Discord側ではWebHookを使用しているため、指定したテキストチャンネルにメッセージを送信、ということができない。
- LINE側からDiscordへのメンションができない。
- スタンプ、画像、動画が送れない。
- BotをグループLINEに入れることが前提で、DMに対応していない。



## 本稿
* 使ったサービス
    - railway
* 言語
    - Python
* やり取りできる内容
    - テキストメッセージ
    - スタンプ(Discord側のものも可、ただし動くスタンプは送信不可)
    - 画像
    - 動画
* 送信形式とか
    - Discord-DiscordAPI
    - LINE-グループ、DM

ホスティングサービスのrailwayを使用して、1つのプログラム内でDiscordBotとLINEBotを同時に稼働させます。
また、LINE側はエンドポイントを増やすことで2つ以上稼働させ、複数のサーバーごとに連携させることが可能です。
先駆者様の場合、グループIDが必須でしたが、指定しない場合DMでのやり取りが可能になります。

画像、動画はDiscordの場合、CDNで保存されるため、LINE側への送信は簡単なのですが、LINEの場合すべてバイナリデータで返されます。
従って画像は画像保存サービスの「Gyazo」、動画は「YouTube」を使用してDiscord側へ送信します。

# 必要なもの、知識

・GitHubのアカウント
・railwayのアカウント
・git
・プログラムをいじれるエディタ(VSCode推奨)
・GayzoのアカウントとAPIキー
・YouTubeアカウントとAPIキー

GyazoとYouTubeに関しては参考リンクを挙げておくので、挙動の確認をお願いします。

https://eng.shibuya24.info/entry/python_gyazo_image_upload

https://qiita.com/ny7760/items/5a728fd9e7b40588237c

YouTubeの方は適当な動画をアップロードしてテストしてください。
GCPに申請する際、アプリケーションの種類はデスクトップにしておきましょう。
2つのjsonファイルが生成されるので控えておきましょう。

YouTubeはちょっとハードルが高いので別途記事を書こうと思います。

# 仕様とか
例として、Discordサーバーによるコミュニティが二つあるとします。
安直ですが、中学時代のコミュニティ、高校時代のコミュニティで、「CHUGAKU」「KOUKOU」で分けます。

LINE側も「CHUGAKU」「KOUKOU」でLINEグループを形成させます。
Botの配置、データの流れとかは以下の図の通りです。


![名称未設定ファイル.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2853914/c4a745ad-5cbc-19ad-ce95-356b0ab29154.png)


CHUGAKU、KOUKOUそれぞれ同じDiscordBotを配置します。
配置したBotは各サーバのメッセージを、railwayで取得します。
railway側でメッセージの種類、送信元サーバーを判断し、LINE用にメッセージを変換します。




※一部改良前の画面を表示してます。
## 基本的なやりとり
![スクリーンショット 2022-09-16 083039.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2853914/ea37bf40-3e65-9883-4a62-6b9306ec6dd3.png)
メッセージ、画像をこんな感じで送受信できます。
## メンション、送信先チャンネル指定
![スクリーンショット 2022-09-16 083244.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2853914/17d8f058-b09e-9e6f-271f-cd8e27372e04.png)
メンション、送信先チャンネルも指定可能です。

(現在、メンションは「@ユーザー名#member」,「@ロール名#role」で指定します。)
## 動画、スタンプ
![スクリーンショット 2022-09-23 211307.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2853914/02d7dac3-ee32-54d0-ec34-9aa42dee1b88.png)

LINE側の動画をYouTubeにアップロードさせて、疑似的に動画を共有させてます。

プログラム側で標準で限定公開(URLを知ってる人しか見れない)に設定してあるので、プライバシーはあまり気にする必要はないです。
## 時報と警告
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2853914/ce0d4f0f-dfb1-b33d-76f2-36b2856653f3.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2853914/fd74b125-07d2-63ba-a148-1142ce99797b.png)

LINEAPIは無料プランの場合、月1000件の上限が設けられています。
これを超える数のメッセージを送ることはできず、活発なサーバーならすぐに上限に達してしまいます。
それを防ぐため1日ごとの上限をプログラム内で計算して定めています。



# テンプレ

## フォークしてデプロイ

https://github.com/maguro-alternative/discordbot_taityo


にアクセス。

README.mdにあるDeploy on Railwayをクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2853914/c54b4849-c2c9-e5c0-7d15-64dae1ab84f1.png)

こんな感じの画面に遷移するのでDeployをクリック。
(Private repositoryにするかはご自由に。一応推奨してます。)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2853914/2f76d8f4-00b5-13d6-0e2b-6fcbf3af4f76.png)


すると早速ビルドが始まります。



しかしこれだけではまだ動きません。まだ環境変数を設定していないので設定します。

## 環境変数の設定

環境変数
Deploymentsの隣のVariablesから環境変数を設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2853914/889558ef-bb12-cc52-19c6-737bf205d6da.png)


右端の設定ボタンから編集できますが、右上にあるRAW Editorで編集しましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2853914/23a24bf1-1b8a-8f20-b10f-2d42032e4128.png)


こんな感じで直感的に編集できます。

さて、環境変数を設定していきましょう。.env.sampleを見てみます。


```
SERVER_NAME=FIVE_SECOND,FIVE_HOUR
FIVE_SECOND_WEBHOOK=
FIVE_SECOND_ACCESS_TOKEN=
FIVE_SECOND_CHANNEL_SECRET=
FIVE_SECOND_GROUP_ID=
FIVE_SECOND_GUILD_ID=
FIVE_SECOND_TEMPLE_ID=
FIVE_SECOND_NG_CHANNEL=
FIVE_HOUR_WEBHOOK=
FIVE_HOUR_ACCESS_TOKEN=
FIVE_HOUR_CHANNEL_SECRET=
FIVE_HOUR_GUILD_ID=
FIVE_HOUR_TEMPLE_ID=
FIVE_HOUR_NG_CHANNEL=
PORT=8080
GYAZO_TOKEN=
TOKEN=
USER_LIMIT=100
CLIENT_SECRET_NAME=
access_token=
client_id=
client_secret=
refresh_token=
project_id=
token_expiry=
VOICEVOX_KEY=
```

**めっちゃ多いです**

一応役割について説明します。

### SERVER_NAME
```
SERVER_NAME=FIVE_SECOND,FIVE_HOUR
```
Discordのサーバーを識別するための環境変数です。
カンマで区切ることで、複数のサーバーを識別できるようにしてます。

上記を例とすると、

* FIVESECOND,FIVEHOURがカンマ区切りなので、運用されるDiscordサーバーは二つ。

* その後の環境変数は以下のように命名する。
```
FIVE_SECOND_WEBHOOK=
FIVE_SECOND_ACCESS_TOKEN=
FIVE_SECOND_CHANNEL_SECRET=
FIVE_SECOND_GROUP_ID=
FIVE_SECOND_GUILD_ID=
FIVE_SECOND_TEMPLE_ID=
FIVE_HOUR_WEBHOOK=
FIVE_HOUR_ACCESS_TOKEN=
FIVE_HOUR_CHANNEL_SECRET=
FIVE_HOUR_GUILD_ID=
FIVE_HOUR_TEMPLE_ID=
```
となります。

被らなければ何でもいいです。

今回は「CHUGAKU」「KOUKOU」なので
```
SERVER_NAME=CHUGAKU,KOUKOU
```
```
CHUGAKU_WEBHOOK=
KOUKOU_WEBHOOK=
```
と命名しましょう。
また、一つだけ指定する場合はカンマ区切りは不要です。



ちょっとわかりづらいと思うのでプログラム例です。

プログラム内ではこういう風に指定してます。
```
servers_name=os.environ['SERVER_NAME']
server_list=servers_name.split(",")
for server_name in server_list:
    os.environ[f"{server_name}_GUILD_ID"]
```

流れ

1:server_nameにCHUGAKU,KOUKOUを代入。

2:server_listにカンマ区切りでCHUGAKUとKOUKOUをそれぞれ配列として代入。

3:forで回す。{server_name}_GUILD_IDはそれぞれ「CHUGAKU_GUILD_ID」,「KOUKOU_GUILD_ID」となります。



こんな感じで環境変数はプログラムが勝手に割り当ててくれます。
ではそれぞれの役割も解説します。

### _WEBHOOK
```
_WEBHOOK
```
Discord側のWebhookです。前述した時報と警告機能を投稿します。
### _ACCESS_TOKEN
```
_ACCESS_TOKEN=
```
LINEBot側のアクセストークンです。例では2つのサーバーで運用しているので、その場合はLINEBotも2つ用意しましょう。
### _CHANNEL_SECRET
```
_CHANNEL_SECRET=
```
LINEBot側のチャネルシークレットキーです。line-bot-sdkでは必須です。
### _GROUP_ID
```
_GROUP_ID=
```
LINEのグループIDです。LINEBotがグループに所属していて、そこで発言させたい場合は必須です。設定しない場合は友達登録している人全員にメッセージが送信されます。

ちなみに例のCHUGAKU(.env.sampleでのFIVE_SECOND)では設定されていますが、KOUKOU(.env.sampleでのFIVE_HOUR)では設定してません。
これによりCHUGAKUにはグループLINEに、KOUKOUは友達登録している人のDMにメッセージを送信します。

### _GUILD_ID
```
_GUILD_ID=
```
Discord側のサーバーIDです。こちらもサーバーの識別に使用します。

それぞれ対応させるサーバーのIDを入れましょう。
### _TEMPLE_ID
```
_TEMPLE_ID=
```
DiscordのチャンネルIDです。基本ここで設定したチャンネルへLINEからDiscordへ送信されます。
### _NG_CHANNEL
```
_NG_CHANNEL=
```
LINE側に送りたくないDiscordのメッセージチャンネルを設定します。
こちらもSERVER_NAMEと同様にカンマ区切りで複数のチャンネルを指定できます。
```
CHUGAKU_NG_CHANNEL=ログ,管理人用
```
こうすることでCHUGAKU側のチャンネル名が「ログ」、「管理人用」の二つのチャンネルのメッセージがLINEに送られなくなります。
特に定めない場合は存在しないチャンネル名を書き込んでください。
こちらも一つだけ指定する場合はカンマ区切りは不要です。



はい！これでLINEBot周りの設定は完了です！
大体これで半分くらいです。

次は他のAPI周りの環境変数になります。
### PORT USER_LIMIT
```
PORT=8080
USER_LIMIT=100
```
PORTはflaskでサーバーを立ち上げるためのポート番号です。
railway側でflaskを使う場合は必ず宣言する必要があるみたいです。（公式で言及してた）

USER_LIMITはDiscordAPIでリクエストを行う際、取得するユーザーの上限を指定してます。
値はなんでもよさそうですが、大きいと処理に時間がかかるかもしれません。
### TOKEN
```
TOKEN=
```
DiscordBotのトークンです。やっと出てきた感じですね(笑)
### GYAZO_TOKEN
```
GYAZO_TOKEN=
```
GyazoAPIのトークンです。

LINE側から画像が送信された場合、アップロードしてURLに変換します。
### YouTube Data API
```
CLIENT_SECRET_NAME=
access_token=
client_id=
client_secret=
refresh_token=
project_id=
token_expiry=
```
YouTube Data API関連のものです。

事前に生成した2つのjsonファイルから参照します。
```
client_secret{ランダム生成された文字列}.json
upload_video.py-oauth2.json
```
このjsonファイル、思いっきりGoogleアカウントの認証に使うキーがあるので、**外部に漏れたらまずいです。**
その部分を環境変数に割り当てます。
CLIENT_SECRET_NAME
「client_secret{ランダム生成された文字列}」(.jsonまでは含まない)が入ります。

要するにjsonファイルの名前です。中身はこうなってると思います。
(os.environを使ってるのはpython内でjsonを生成するからです。)
```python
{
  "installed":
  	        {
  	            "client_id":os.environ["client_id"],
  	            "project_id":os.environ["project_id"],
  	            "auth_uri":"https://accounts.google.com/o/oauth2/auth",
  	            "token_uri":"https://oauth2.googleapis.com/token",
  	            "auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs",
  				"client_secret":os.environ["client_secret"],
  				"redirect_uris":["http://localhost"]
  			}
  }
```
cilent_id,project_id,client_secretそれぞれ環境変数に該当するものになります。割り当てていきましょう。
```python
oau={
	    "access_token":os.environ["access_token"],
	    "client_id":os.environ["client_id"],
	    "client_secret":os.environ["client_secret"],
	    "refresh_token":os.environ["refresh_token"],
	    "token_expiry": os.environ["token_expiry"], 
	    "token_uri": "https://oauth2.googleapis.com/token",
	    "user_agent": None,
	    "revoke_uri": "https://oauth2.googleapis.com/revoke", 
	    "id_token": None, 
	    "id_token_jwt": None, 
	    "token_response": {
	        "access_token":os.environ["access_token"],
	        "expires_in": 3599, 
	        "scope": "https://www.googleapis.com/auth/youtube.upload", 
	        "token_type": "Bearer"
	    },
	    "scopes": ["https://www.googleapis.com/auth/youtube.upload"], 
	    "token_info_uri": "https://oauth2.googleapis.com/tokeninfo", 
	    "invalid": False, 
	    "_class": "OAuth2Credentials", 
	    "_module": "oauth2client.client"
	}
```
upload_video.py-oauyh2.jsonの中身です。

access_token,refresh_token,token_expiryの該当する部分にこちらも割り当てます。



これでDiscordBot側の最低限の動作ができるようになります。
### VOICEVOX_KEY
```
VOICEVOX_KEY=
```

ずんだもんで有名なvoicevoxのAPIキーです。

必須ではありませんが、割り当てるとずんだもんが「/zunda」で読み上げしてくれます。

APIキーの取得方法はこちらから。

https://voicevox.su-shiki.com/su-shikiapis/

# LINE Messaging API側のWebhook
前述の通り、LINEBot側は2つ必要です。

それぞれにWebhookを設定します。



CHUGAKU側のLINEBotは
```
https://{railwayのプロジェクトにあるURL}/CHUGAKU
```
KOUKOU側のLINEBotは
```
https://{railwayのプロジェクトにあるURL}/KOUKOU
```
と設定します。

{railwayのプロジェクトにあるURL}はDeploymentsで確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2853914/ec54d7e5-c492-05f6-6f53-88503dd65c92.png)

見れば分かると思いますがSERVER_NAMEで設定した名前がエンドポイントになります。

レスポンスが200(成功)と帰ってくるのを忘れずに確認しておきましょう。



# LINE側を1つ、または3つ以上運用する場合
前述の通り、flask側のプログラムをいじる必要があります。

理由として、LINEBot1つにつき1個、エンドポイントを増やさなければならないからです。

(本当は自動生成させたかったんだけどエラー吐きまくってダメでした。)

なのでgit cloneでフォークしたテンプレをローカルに落とします。



ディレクトリをapp/serversへ移動させると
```
main_server.py
five_hour.py
```
の2つのPythonファイルがあると思います。

main_server.pyがFIVE_SECOND側のLINEBotのプログラムで、five_hour.pyがFIVE_HOUR側のプログラムになります。

app/servers/main_server.pyの17行目から
```python:main_server.py
from servers.five_hour import app2
from servers.bin.disreq import message_find,img_message,download

app = Flask(__name__)

app.register_blueprint(app2)
```


17行目は同階層のfive_hour.pyをインポートし、app2として扱います。
app.register_blueprint(app2)で分割されているプログラムを統合しています。

こうする事でLINEBotを分割しつつ二つホストしています。


## 1つだけ運用したい場合
1つだけ運用したい場合、説明した部分を削除し、環境変数SERVER_NAMEからも消しましょう。
例としてCHUGAKUだけ運用したい場合はこうします。
### インポートを無効
app/servers/main_server.pyの17行目から
```diff_python:main_server.py
- from servers.five_hour import app2
from servers.bin.disreq import message_find,img_message,download

app = Flask(__name__)

- app.register_blueprint(app2)
```

### 環境変数から削除
```
SERVER_NAME=CHUGAKU
```

## 3つ以上運用したい場合
3つ以上運用したい場合、このように追加していきます。

Discordのサーバー名は「DAIGAKU」にしておきます。

### 1.Pythonファイルの追加
app/servers に新しいPythonファイルを作成します。
名前はなんでもいいですが、ここではdaigaku.pyにします。

### 2.コード追加
daigaku.pyに以下のコードを追加します。
```python:daigaku.py
from flask import request, abort, Blueprint ,current_app
import subprocess

from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
   InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, ImageMessage, VideoMessage, StickerMessage
)
import os
from servers.bin.disreq import message_find,img_message,download
   
app3 = Blueprint("app3",__name__)

servers_name=os.environ['SERVER_NAME']
server_list=servers_name.split(",")

server_name=server_list[2]
    	
line_bot_api = LineBotApi(os.environ[f'{server_name}_ACCESS_TOKEN'])
handler = WebhookHandler(os.environ[f'{server_name}_CHANNEL_SECRET'])
    	
    
@app3.route(f"/{server_name}", methods=['POST'])
def callbacks():
		logger = current_app.logger
	    # get X-Line-Signature header value
		signature = request.headers['X-Line-Signature']
	

	    # get request body as text
		body = request.get_data(as_text=True)
		logger.info("Request body: " + body)
		#app2.logger.info("Request body: " + body)
	

	    # handle webhook body
		try:
			handler.handle(body, signature)
		except InvalidSignatureError:
			print("Invalid signature. Please check your channel access token/channel secret.")
			abort(400)
	

		return 'OK'


@handler.add(MessageEvent, message=[TextMessage,ImageMessage,VideoMessage,StickerMessage])
def handle_message(event:MessageEvent):
    event_type=event.message.type
    # 2022/10/02追記:友達登録していない場合グループメッセージが読み取られない不具合を修正。
    try:
        profile = line_bot_api.get_profile(event.source.user_id)
    except LineBotApiError:
        profile = line_bot_api.get_group_member_profile(os.environ[f"{server_name}_GROUP_ID"],event.source.user_id)

    if event_type=='text':
        message_text=event.message.text
    if event_type=='sticker':
        message_text=f"https://stickershop.line-scdn.net/stickershop/v1/sticker/{event.message.sticker_id}/iPhone/sticker_key@2x.png"
    if event_type=='image':
        # message_idから画像のバイナリデータを取得
        message_content = line_bot_api.get_message_content(event.message.id).content
        message_text=img_message(message_content)
    if event_type=='video':
        message_content = line_bot_api.get_message_content(event.message.id)
        download(message_content)
        youtube_id = subprocess.run(['python', 'upload_video.py', f'--title="{profile.display_name}の動画"','--description="LINEからの動画"'], capture_output=True)
        message_text = f"https://youtu.be/{youtube_id.stdout.decode()}"
    message_find(
        message_text,
        os.environ[f"{server_name}_GUILD_ID"],
        os.environ[f"{server_name}_TEMPLE_ID"],
        profile
    )
```
2022/09/30追記:
友達登録していない場合グループメッセージが読み取られない不具合を修正。
友達登録しているユーザーのみが読み取り可能だったため。
### 3.コード追加
app/servers/main_server.pyにコードを追加します。
```diff_python:main_server.py
from servers.five_hour import app2
+ from servers.daigaku import app3
from servers.bin.disreq import message_find,img_message,download

app = Flask(__name__)

app.register_blueprint(app2)
+ app.register_blueprint(app3)
```
### 4.環境変数
環境変数SERVER_NAMEにDAIGAKUを追加します。
```
SERVER_NAME=CHUGAKU,KOUKOU,DAIGAKU
```	
### 5.LINEWebHookの設定
LINEBot側のWebhookを指定します。

```
https://{railwayのプロジェクトにあるURL}/DAIGAKU
```

以上です。4つ目以降も追加する場合も同様にやります。

ただし、

server_nameはserver_list[3],server_list[4]

app2,app3,app4

と追加するたびに変更するのを忘れずに。



# 完成！！
これで完成です。

多分やりとりできると思うのでなんか試しに送ってみましょう。

# 参考
[【ゼロから解説】LINEとDiscordのグループをbotで接続する【無料･高速･鯖いらず】](https://qiita.com/i_tatte/items/6cd8d9ce0a93df249937 "LINE-Discord")
[PythonでGyazoに画像をアップロードする方法](https://eng.shibuya24.info/entry/python_gyazo_image_upload)
[YouTubeAPIを利用して動画をアップロードする](https://qiita.com/ny7760/items/5a728fd9e7b40588237c)
[RailwayでDiscord Botをホストしてみた](https://qiita.com/sevenc-nanashi/items/29b1ff92a5dd03b30172)