DiscordとGoogleSpreadSheetのファイル送受信

# はじめに
DiscordとGoogleSpreadSheet(以下GSS)を連携して書き込んだり読み込んだりしたい！という考えから作成したもの

結果できたものは
1. Discordで送信したメッセージや、ファイルのURLをGSSに書き込み
2. GoogleAppsScript(以下GAS)で送信したメッセージやファイルをDiscordに書き込み
3. 送信したファイルのURLをGSSに書き込み


使用言語は以下
- python3.6.4
- HTML5
- JavaScript
- Vue.js
- GAS

ちなみにVue.jsを使ってるのは筆者の趣味。使わなくても問題ないはず(試してないけど)

# Discordから送信

## 全体ソースコード
```python:DiscordBot.py
import discord
import gspread
import json

client = discord.Client()

#ServiceAccountCredentials：Googleの各サービスへアクセスできるservice変数を生成します。
from oauth2client.service_account import ServiceAccountCredentials 

@client.event
async def on_ready():
    print('Logged in as '+ client.user.name)

@client.event
async def on_message(message):
    if message.author != client.user:  # 自分以外からのメッセージのときに反応

        # 特定のチャンネルでない場合は何もしない
        if message.channel.name != 'チャンネル名':
            return

        #2つのAPIを記述しないとリフレッシュトークンを3600秒毎に発行し続けなければならない
        scope = ['https://spreadsheets.google.com/feeds','https://www.googleapis.com/auth/drive']

        #認証情報設定
        #ダウンロードしたjsonファイル名をクレデンシャル変数に設定（秘密鍵、Pythonファイルから読み込みしやすい位置に置く）
        credentials = ServiceAccountCredentials.from_json_keyfile_name('jsonファイルパス', scope)

        #OAuth2の資格情報を使用してGoogle APIにログインします。
        gc = gspread.authorize(credentials)

        #共有設定したスプレッドシートキー
        SPREADSHEET_KEY = 'スプレッドシートキー'

        #共有設定したスプレッドシートのシートを開く
        worksheet = gc.open_by_key(SPREADSHEET_KEY).worksheet("シート名")
        
        if message.author != client.user: # 誰かから送信された場合
            
            try:
                #メッセージ内容
                export_message = message.content

                #ファイルURL 
                export_value = message.attachments[0].url

                #ファイル名
                export_name = message.attachments[0].filename

                for num in range(1,996):

                    # A列の値
                    import_value = worksheet.cell(num , 1).value

                    if len(import_value) == 0:

                        # セルに書き込み
                        worksheet.update_cell(num,2, export_message)
                        worksheet.update_cell(num,3, export_value)
                        return
            except IndexError:
                print('IndexError:'+ message.author.name)
                pass
        else:
            return
        
# Discordのデベロッパサイトで取得したトークン
client.run("トークン")
```

## 解説

Discordから送信する場合はGSS側に書き込み許可を出さないといけなかったりするので事前準備がいくつか必要です。

ここで説明すると長くなるので以下のサイトを参考にしてください
[【もう迷わない】Pythonでスプレッドシートに読み書きする初期設定まとめ](https://tanuhack.com/operate-spreadsheet/)

やることは単純で、
- 書き込まれたメッセージやファイルを解読する
- 指定したGSSのセルに書き込む
の2つだけです


> ```python:DiscordBot.py
> export_message = message.content
> export_value = message.attachments[0].url
> export_name = message.attachments[0].filename
> ```
> 
> メッセージやファイルを解読するための処理です。
> 
> `message.content`で書き込まれたメッセージを取得できます
> `message.attachments[0].filename`でファイル名を取得できます
> `message.attachments[0].url`でファイルURLを取得できます

　

> ```python:DiscordBot.py
> worksheet = gc.open_by_key(SPREADSHEET_KEY).worksheet("シート名")
> 
> for num in range(1,996):
>     # A列の値
>     import_value = worksheet.cell(num , 1).value
> 
>     if len(import_value) == 0:
>         # セルに書き込み
>         worksheet.update_cell(num,2, export_message)
>         worksheet.update_cell(num,3, export_value)
>         return
> ```
> 
> セルに書き込むための処理です。
> 
> SPREADSHEET_KEYとシート名が一致するシートに書き込みます
> `worksheet.update_cell(1,1, 'AAA')`でセルA1にAAAを書き込みます
> 
> 書き込むための記述はGASと大して変わらないので比較的わかりやすいと思います。  


# GASから送信

## 全体ソースコード
```html:Index.html
<!DOCTYPE html>
<html>
    <head>
    <base target="_top">
        <meta charset="utf-8" content="width=device-width,initial-scale=1.0"/>
    </head>
    <body>
        <main id="main" class="main">
            <form enctype="multipart/form-data">
                    <label>テキスト</label><br>
                    <input type="text" v-model="Text" size="45"><br>
                    <label>テキストエリア</label><br>
                    <textarea type="text" v-model="TextArea" rows="10" cols="40">
                    </textarea><br>
                    <label>ファイル</label><br>
                    <input type="file" name="myFile" id="file" accept="application/zip" v-on:change="changeFile"/>
                    <br>
                <button type="button" type="submit" id="upload" v-on:click="Submit(this)">
                    送信
                </button>
            </form>
        </main>
        <?!= HtmlService.createHtmlOutputFromFile('Index.js').getContent(); ?>
    </body>
</html>
```
```html:Index.js
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>

<script>
var tmpText = "";
var tmpTextArea = "";
var tmpFile = "";

var main = new Vue({
    el: '#main',
    data: function() {
        return{
            Text:"",
            TextArea:"",
            file:"",
        }
    },
    methods: {
        
        // シート書き込み処理
        pushSheetData:function(e){
            var reader = new FileReader();
            reader.readAsDataURL(this.file);

            tmpText = this.Text;
            tmpTextArea = this.TextArea;
            tmpFile = this.file;
            reader.onload = function(evt) {
            
                var data =  reader.result.split(",");
                wkFileData = {fileName: tmpFile.name, mimeType: data[0].match(/:(\w.+);/)[1] ,data: data[1]};
                
                google.script.run.withSuccessHandler(function(arg){
                    console.log("Arg:"+arg);
                    alert("ファイルのアップロードに成功しました。");
                }).withFailureHandler(function(arg){
                    console.log("Arg:"+arg);
                    alert("ファイルのアップロードに失敗しました。");
                }).processForm(tmpText,tmpTextArea,wkFileData);
            }
        },
        
        // 送信ボタン押下時処理
        Submit:function(e){
            this.pushSheetData(e);
            
            //初期化
            this.Text="";
            this.TextArea="";
        },

        // アップロードされたファイル
        changeFile: function (e) {
            const target = e.target.files;
            if(target.length != 0){
                this.file = target[0];
            }
        },
    }
})
</script>
```
```javascript:Servlet.gs
function doGet(e){
  var htmlOutput = HtmlService.createTemplateFromFile("Index").evaluate();
  htmlOutput.setTitle('Sample').addMetaTag('viewport', 'width=device-width, initial-scale=1');
  return htmlOutput;
}

//Htmlから取得したファイルを変換する
function processForm(Text,TextArea,formObject) {
    
    // 指定したユーザにメンションを送れる
    const mention = "<@18桁の数字> "

    // ファイルを送信できる形式に変換
    var blobs = Utilities.newBlob(Utilities.base64Decode(formObject.data), formObject.mimeType, formObject.fileName);
    
    // テキストエリアの入力値とファイルを送信
    discordSend(mention+TextArea,blobs);

    // シートに書き込み
    setSheetData(Text, TextArea);
    
    return blobs;
}

//Discordに送信
function discordSend(message,file) {
    // 各所必要な項目をセットします
    const url        = 'discordのwebhooksのurl';
    const token      = 'Discordのデベロッパサイトで取得したトークン';
    const channel    = '#送信したいチャンネル名';
    const text       = message;//送信するメッセージ
    const username   = '送信させるユーザ名';
    const parse      = 'full';
    const method     = 'post';
    const attachment   = file;//送信するファイル

    const payload = {
        'token'      : token,
        'channel'    : channel,
        "content"    : text,
        'username'   : username,
        'parse'      : parse,
        'attachment1': attachment,
    };

    const params = {
        'method' : method,
        'payload' : payload,
        'muteHttpExceptions': true

    };
    response = UrlFetchApp.fetch(url, params);
}

//対象のシートに取得したデータを書き込み
function setSheetData(Text, TextArea){
    var sheetName = SpreadsheetApp.openById("スプレッドシートID").getSheetByName("シート名"); 
    
    //A列の最終行を判断
    var last_row = sheetName.getRange('A:A').getValues().filter(String).length;
    last_row++;
    
    //スプレッドシートに挿入
    sheetName.getRange("A"+last_row).setValue(Text);
    sheetName.getRange("B"+last_row).setValue(TextArea);
}
```
```python:DiscordBot.py
import discord
import gspread
import json

client = discord.Client()

#ServiceAccountCredentials：Googleの各サービスへアクセスできるservice変数を生成します。
from oauth2client.service_account import ServiceAccountCredentials 

@client.event
async def on_ready():
    print('Logged in as '+ client.user.name)

@client.event
async def on_message(message):
    if message.author != client.user:  # 自分以外からのメッセージのときに反応

        # 特定のチャンネルでない場合は何もしない
        if message.channel.name != 'チャンネル名':
            return

        #2つのAPIを記述しないとリフレッシュトークンを3600秒毎に発行し続けなければならない
        scope = ['https://spreadsheets.google.com/feeds','https://www.googleapis.com/auth/drive']

        #認証情報設定
        #ダウンロードしたjsonファイル名をクレデンシャル変数に設定（秘密鍵、Pythonファイルから読み込みしやすい位置に置く）
        credentials = ServiceAccountCredentials.from_json_keyfile_name('jsonファイルパス', scope)

        #OAuth2の資格情報を使用してGoogle APIにログインします。
        gc = gspread.authorize(credentials)

        #共有設定したスプレッドシートキー
        SPREADSHEET_KEY = 'スプレッドシートキー'

        #共有設定したスプレッドシートのシートを開く
        worksheet = gc.open_by_key(SPREADSHEET_KEY).worksheet("シート名")
        
        if message.author.name == client.user.name: # 名前が一致する場合
            # スプレッドシートから送信された場合

            try:
                for num in range(1,996):

                    # A列の値
                    import_value = worksheet.cell(num , 1).value
                    
                    if len(import_value) == 0:

                        # ファイルURL
                        export_value = message.attachments[0].url

                        # セルに書き込み
                        worksheet.update_cell(num,1, export_value)
                        return
            except IndexError:
                print('IndexError:'+ message.author.name)
                pass

        
# Discordのデベロッパサイトで取得したトークン
client.run("トークン")
```

## 解説

GAS単体ではGASからメッセージやファイルを送信することはできるのですが、送信したファイルのURLを取得することはできません。(というかあるんですかね)  

なので  

- GASから受け取ったメッセージやファイルをDiscordへ送信する。(GSSへの書き込みも同時に行う)
- discord.pyを利用して送信されたファイルのURLをGSSに書き込む

ということをしています。


> ```javascript:Index.js
> pushSheetData:function(e){
>     var reader = new FileReader();
>     reader.readAsDataURL(this.file);
> 
>     tmpText = this.Text;
>     tmpTextArea = this.TextArea;
>     tmpFile = this.file;
>     reader.onload = function(evt) {
>     
>         var data =  reader.result.split(",");
>         wkFileData = {fileName: tmpFile.name, mimeType: data[0].match(/:(\w.+);/)[1] ,data: data[1]};
>         
>         google.script.run.withSuccessHandler(function(arg){
>             console.log("Arg:"+arg);
>             alert("ファイルのアップロードに成功しました。");
>         }).withFailureHandler(function(arg){
>             console.log("Arg:"+arg);
>             alert("ファイルのアップロードに失敗しました。");
>         }).processForm(tmpText,tmpTextArea,wkFileData);
>     }
> }
> ```
> 
> ```javascript:Servlet.gs
> function processForm(Text,TextArea,formObject) {
>     
>     // 指定したユーザにメンションを送れる
>     const mention = "<@18桁の数字> "
> 
>     // ファイルを送信できる形式に変換
>     var blobs = Utilities.newBlob(Utilities.base64Decode(formObject.data), formObject.mimeType, formObject.fileName);
>     
>     // テキストエリアの入力値とファイルを送信
>     discordSend(mention+TextArea,blobs);
>     
>     return blobs;
> }
> ```
> ローカルからアップロードするファイルを扱うための処理です。
>   
> Googleドライブに書き込むときも同様にできます。
> メンションを飛ばしたい場合はDiscordで開発者モードを使い取得したユーザのIDを利用し`"<@18桁の数字> "`のように記述します。[^1]

　


>   
> ```javascript:Servlet.gs
> function discordSend(message,file) {
>     // 各所必要な項目をセットします
>     const url        = 'discordのwebhooksのurl';
>     const token      = 'Discordのデベロッパサイトで取得したトークン';
>     const channel    = '#送信したいチャンネル名';
>     const text       = message;//送信するメッセージ
>     const username   = '送信させるユーザ名';
>     const parse      = 'full';
>     const method     = 'post';
>     const attachment   = file;//送信するファイル
> 
>     const payload = {
>         'token'      : token,
>         'channel'    : channel,
>         "content"    : text,
>         'username'   : username,
>         'parse'      : parse,
>         'attachment1': attachment,
>     };
> 
>     const params = {
>         'method' : method,
>         'payload' : payload,
>         'muteHttpExceptions': true
> 
>     };
>     response = UrlFetchApp.fetch(url, params);
> }
> ```
> 
> Discordへ送信するための処理です。
> 
> `const username`の部分に送信させたいユーザを、
> `const text`の部分に送信したいメッセージを、
> `const attachment`の部分に送信したいファイルを格納します。
> 
> webhooksのURLは注釈2[^2]が、トークンについては注釈1[^1]あたりが参考になる。

　

> 
> ```python:DiscordBot.py
> if message.author.name == client.user.name: # 名前が一致する場合
>     for num in range(1,996):
>         # A列の値
>         import_value = worksheet.cell(num , 1).value
>         
>         if len(import_value) == 0:
>             # ファイルURL
>             export_value = message.attachments[0].url
>             # セルに書き込み
>             worksheet.update_cell(num,1, export_value)
>             return
> ```
> 
> 送信したファイルをセルに書き込むための処理です。  
> これはDiscordから送信する時とさほど変わりません。
> ただ、GASから送信したユーザは同じ名前でも全く別のユーザ(IDが#0000)となるので名前が一致する場合で判別してます(どうやったらうまく判別できるのかは知りたいところ)

# あとがき
Discordで管理するとログが流れるので、履歴をたどるのがめんどくさかったから割と使いやすいんじゃね？って思ってる。
それにGoogleフォームとかも自作できるし、Googleドライブの容量を気にする必要もないし、と応用は利きそう。

ただ不安なのが同期まわり。GASから送信する際、ファイルURLは後から書き込んでるからズレたりしないだろうか(Discord.py側でまとめてセルに書き込めばいい話なんだろうけど)。


[^1]: https://support.discordapp.com/hc/ja/articles/206346498-ユーザー-サーバー-メッセージIDはどこで見つけられる-
[^2]: https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks