Slackから自社サーバーにファイルをアップロードしちゃう

こんにちは。
営業やプリセールスなんかやってる人は、社外の方へ資料をメールで送ることなんて良くありますよね。しかし動画など大きいサイズのデータをメールで送ろうとするとサイズオーバーで添付できない！で、仕方なく外部のファイルアップローダーを使うも、毎回これはめんどくさい、、、

というあるあるを見事解決してやりました。
先にまずどんなことができるか、その次に作った手順なんかを記載します。

# 何ができるの？

Slackの特定のチャンネルにファイルを送信すると、自社のファイルサーバーにアップロードされ、そのURLが返信される　という仕組みです。
便利でしょ。一瞬ですよ。
![スクリーンショット 2021-03-17 20.27.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/500613/af6feaf7-cbe0-d5c7-069a-b23bb6709d04.png)
（その部分をキャプチャ撮ってGIFアニメ作って貼ろうかと思ったんですが、そちらは一瞬でできそうになかったので私は諦めたんです。）

本プロジェクトの機能はざっくりと３つほどあります。

<details><summary>3つの機能</summary><div>

## 【メイン】アップロード機能

上にも書きましたが、特定のチャンネルにファイルを送信すると、自社のファイルサーバーにアップロードされ、そのURLが返信されます。
また、アップロード時にサーバーのディスクが逼迫している場合、アップロードせずにエラーを返します。

## 【オプション】ファイル一覧表示機能

チャンネル内で`/list`というコマンドを打つと、今アップロードされているファイルの一覧を返します。

## 【オプション】ファイル削除機能

チャンネル内で`/del [dir name/file name]`というコマンドを打つと、指定したファイル削除を削除します。
また、アップロードから一定期間経過したファイルは自動削除します。

という感じです。便利でしょ。（2回目）

</div></details>

# 作成手順

Slack APIの設定、サーバーの環境構築、サーバーアプリの開発、の３つの作業が必要です。
便宜上、Slack側のアプリをSlackApp、サーバーアプリをServerAppと呼びます。

## サーバーアプリの開発

<details><summary>サーバーアプリの開発内容です。VSCodeからRemoteSSHにて行います。</summary><div>

プロジェクト構成です。

``` sh:dirmap
fileuploader/
├── build/                   // ビルド用のディレクトリ
│   └── build.sh             // ビルド用のシェルスクリプト
├── configs/                 // コンフィグ用のディレクトリ
│   ├── config.toml          // コンフィグ
│   └── sample-config.toml   // コンフィグのサンプル
├── output/                  // ビルドの出力先のディレクトリ
├── service/                 // サービス用のディレクトリ
│   └── FileUploader.service // サービスのユニットファイル
├── uploader/                // ソースコード用のディレクトリ
│   ├── config.go            // コンフィグ処理
│   ├── file.go              // ファイル処理
│   ├── main.go              // メイン処理
│   ├── server.go            // サービス処理
│   └── slack.go             // スラック処理
├── .gitignore               // Gitバージョン非管理指定ファイル
├── go.mod                   // Go Modulesの管理ファイル
├── go.sum                   // 依存モジュールのチェックサム管理ファイル
└── README.md                // 本プロジェクトのREADME
```

ここからソースコード

メイン処理です。
config.goにて初期処理をクリアしたら、server.goにてサービスを起動します。

```golang:main.go
package main

import (
    "fmt"
    "os"
    "path/filepath"
)

var config Config

// 初期処理
func initialize() bool {
    exe, _ := os.Executable()
    // コンフィグファイルの読み込み
    config = GetConfig()
    if _, err := os.Stat(config.File.Path); err != nil {
        fmt.Println("not config.", err)
        return false
    }
    return true
}

// メイン処理
func main() {
    // 初期処理に失敗したら終了
    if !initialize() {
        return
    }
    // サービスを起動
    service()
}
```

コンフィグ処理です。
config.tomlのパラメーターを一致させて読み込みます。

```golang:config.go
package main

import (
    "fmt"
    "os"
    "path/filepath"
    "github.com/BurntSushi/toml"
)

// コンフィグ
type Config struct {
    Service ServiceConfig
    Slack   SlackConfig
    File    FileConfig
    Log     LogConfig
}

// コンフィグのServiceConfig
type ServiceConfig struct {
    Port string `toml:"Port"`
}

// コンフィグのSlackConfig
type SlackConfig struct {
    FileURL  string `toml:"FileURL"`
    BotToken string `toml:"BotToken"`
    Channel  string `toml:"Channel"`
    ChatURL  string `toml:"ChatURL"`
}

// コンフィグのFileConfig
type FileConfig struct {
    Path  string `toml:"Path"`
    DLURL string `toml:"DLURL"`
    Fsys  string `toml:"Fsys"`
    Remit int64  `toml:"Remit"`
}

// コンフィグ読込処理
func GetConfig() (config Config) {
    // toml形式のファイルを読み込む
    exe, _ := os.Executable()
    confPath := filepath.Join(filepath.Dir(exe), "configs", "config.toml")
    _, err := toml.DecodeFile(confPath, &config)
    if err != nil {
        fmt.Println(err.Error())
    }
    return config
}
```

サービス処理です。
`/file-uploader`と`/file-cmd`の2つのhttpサービスを立ち上げます。
httpの送受信を行います。

```golang:server.go
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
    "github.com/gin-gonic/gin"
)

// サービス処理
func service() {
    router := gin.Default()
    // POST受信
    router.POST("/file-uploader", RecvPostUp)
    router.POST("/file-cmd", RecvPostCmd)
    // 起動
    router.Run(":" + config.Service.Port)
}

// ファイルアップロード受信処理
func RecvPostUp(ctx *gin.Context) {
    // JSONを取得
    var req URLReq
    if err := ctx.ShouldBindJSON(&req); err != nil {
        ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    logText := fmt.Sprintf("Recv POST, type:%s token:%s", req.Type, req.Token)
    fmt.Println(logText)
    // リクエストタイプで分岐
    if req.Type == reqTypeURL {
        ctx.JSON(http.StatusOK, URLRes{req.Challenge})
    } else if req.Type == reqTypeEvt {
        EventCallback(req)
    } else {
        // 処理しない
    }
    fmt.Println()
}

// コマンド受信処理
func RecvPostCmd(ctx *gin.Context) {
    fmt.Println("Recv POST file-cmd.")
    // POSTフォームを取得
    var req ReqCmd
    req.ChannelID = ctx.PostForm("channel_id")
    req.Command = ctx.PostForm("command")
    req.Text = ctx.PostForm("text")
    logText := fmt.Sprintf("Recv POST, ch_id:%s cmd:%s text:%s", req.ChannelID, req.Command, req.Text)
    fmt.Println(logText)
    // 指定のチャンネル以外はスルー
    if req.ChannelID != config.Slack.Channel {
        return
    }
    // コマンドの分岐
    if req.Command == "/list" {
        // アップロード済みファイル一覧を取得
        result := DispFileList()
        // チャット送信
        msg := "List of Uploaded Files.\n" + result
        SendChat(msg)
    } else if req.Command == "/del" {
        msg := DelFile(req.Text)
        SendChat(msg)
    } else {
        // 処理しない
    }
    fmt.Println()
}

// POSTを送信する処理
func SendPost(body []byte) {
    fmt.Println(fmt.Sprintf("request body:%s", string(body)))
    // POSTリクエストを生成
    req, _ := http.NewRequest("POST", config.Slack.ChatURL, bytes.NewBuffer(body))
    req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Slack.BotToken))
    req.Header.Add("Content-Type", "application/json")
    fmt.Println(fmt.Sprintf("request:%s", req.URL.String()))
    // 実行
    client := &http.Client{}
    res, _ := client.Do(req)
    // レスポンスのボディを取得
    resBody, _ := ioutil.ReadAll(res.Body)
    defer res.Body.Close()
    resJSON := new(FileRes)
    if err := json.Unmarshal(resBody, resJSON); err != nil {
        fmt.Println("JSON Unmarshal error:", err)
        return
    }
    fmt.Println("SendChat response:" + string(resBody))
    return
}
```

Slack専用処理です。
server.goが受けたリクエストの中で、Slackに関わる処理を行います。

```golang:slack.go
package main

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
)

// "event_callback"を受ける際のリクエスト
type URLReq struct {
    Type      string `json:"type"`
    Token     string `json:"token"`
    Challenge string `json:"challenge"`
    Event     Event  `json:"event"`
    Text      string `json:"text"`
}

// URLReqのEvent
type Event struct {
    Type    string `json:"type"`
    FileID  string `json:"file_id"`
    Channel string `json:"channel"`
    Text    string `json:"text"`
}

// "event_callback"を受けた際のレスポンス
type URLRes struct {
    Challenge string `json:"challenge"`
}

// SlackのファイルサーバーからファイルをDLする際のリクエスト
type FileReq struct {
    Token string `json:"token"`
    File  string `json:"file"`
}

// SlackのファイルサーバーからファイルをDLする際のレスポンス
type FileRes struct {
    OK   bool `json:"ok"`
    File File `json:"file"`
}

// FileResのFile構造体
type File struct {
    Name string `json:"name"`
    URL  string `json:"url_private_download"`
    Type string `json:"mimetype"`
}

// SlashCommandを受ける際のリクエスト
type ReqCmd struct {
    Token     string `json:"token"`
    ChannelID string `json:"channel_id"`
    Command   string `json:"command"`
    Text      string `json:"text"`
    ResURL    string `json:"response_url"`
}

// SlashCommandを受ける際のレスポンス
type ResCmd struct {
    Chnl   string `json:"channel"`
    Text   string `json:"text"`
    Parse  string `json:"parse"`
    AsUsr  bool   `json:"as_user"`
    UnfURL bool   `json:"unfurl_links"`
}

const (
    // 認証時のリクエストタイプ
    reqTypeURL string = "url_verification"
    // イベント時のリクエストタイプ
    reqTypeEvt string = "event_callback"
    // ファイル共有時のリクエストタイプ
    evtTypeShr string = "file_shared"
)

// イベントキャッチ時の処理
func EventCallback(req URLReq) {
    logText := fmt.Sprintf("Event info, event_type:%s file_id:%s", req.Event.Type, req.Event.FileID)
    fmt.Println(logText)
    // パラメーターのチェック
    if (req.Event.FileID == "") || (req.Event.Type != evtTypeShr) {
        fmt.Println(fmt.Sprintf("type:%s text:%s file_iD:%s ", req.Event.Type, req.Event.Text, req.Event.FileID))
        return
    }
    // 格納先ディスクのチェック
    if !IsAllowance() {
        fmt.Println("isPressing.")
        SendChat("ファイルサーバーの空き容量が不足しています。")
        return
    }
    // ファイルのアップロード
    var fileReq FileReq
    fileReq.Token = config.Slack.BotToken
    fileReq.File = req.Event.FileID
    result := FileUpload(fileReq)
    // Slackにチャットを返信
    SendChat(result)
}

// ファイルをSlackから取得してサーバーにアップロードするまでの処理
func FileUpload(fileReq FileReq) (result string) {
    logText := fmt.Sprintf("File download info, api_url:%s token:%s", config.Slack.FileURL, fileReq.Token)
    fmt.Println(logText)
    // ファイル取得用URLを取得
    res := GetFileURL(config.Slack.FileURL, fileReq.Token, fileReq.File)
    if !res.OK {
        result = "fail download."
        fmt.Println(result)
        return result
    }
    logText = fmt.Sprintf("file_url:%s", res.File.URL)
    fmt.Println(logText)
    // ファイル処理
    result = OperateFile(res.File.URL, res.File.Name)
    fmt.Println(result)
    return result
}

// ファイルIDで指定したファイルのURLを取得する処理
func GetFileURL(url, token, file string) (result *FileRes) {
    req, _ := http.NewRequest("GET", url, nil)
    // パラメーターのセット
    params := req.URL.Query()
    params.Add("token", token)
    params.Add("file", file)
    req.URL.RawQuery = params.Encode()
    fmt.Println(req.URL.String())
    // 実行
    client := &http.Client{}
    res, _ := client.Do(req)
    // レスポンスのボディを取得
    resBody, _ := ioutil.ReadAll(res.Body)
    defer res.Body.Close()
    resJSON := new(FileRes)
    if err := json.Unmarshal(resBody, resJSON); err != nil {
        fmt.Println("JSON Unmarshal error:", err)
        return
    }
    fmt.Println("GetFileURL response:" + string(resBody))
    return resJSON
}

// アップロード済みファイル一覧を返す処理
func DispFileList() string {
    files, err := ioutil.ReadDir(config.File.Path)
    if err != nil {
        panic(err)
    }
    var paths string = ""
    var count int = 1
    for _, file := range files {
        if file.IsDir() {
            paths += fmt.Sprintf("%2d) %s\n", count, DirWalk(file.Name()))
            count++
            continue
        }
    }
    fmt.Println(paths)
    return paths
}

// チャットを送信する処理
func SendChat(msg string) {
    // ボディのセット
    var res ResCmd
    res.Chnl = config.Slack.Channel
    res.Text = msg
    res.Parse = "none"
    res.AsUsr = true
    res.UnfURL = true
    body, _ := json.Marshal(res)
    fmt.Println(fmt.Sprintf("request body:%s", string(body)))
    // POST送信
    SendPost(body)
}

```

ファイル処理です。
ディレクトリやファイルを扱います。

```golang:file.go
package main

import (
    "crypto/sha1"
    "fmt"
    "io/ioutil"
    "net/http"
    "os"
    "os/exec"
    "path/filepath"
    "strconv"
    "strings"
    "time"
)

// メインのファイル処理
func OperateFile(fURL, name string) string {
    var logText string
    // ファイルのダウンロード
    body := GetFile(fURL)
    // 現在時刻からハッシュ値を生成し、それを名前としたディレクトリを作成
    nowtime := time.Now()
    hash := CreateHash(nowtime.String())
    dir := filepath.Join(config.File.Path, hash)
    fmt.Println("dir path:" + dir)
    if !CreateDir(dir) {
        logText = "dir create error."
        fmt.Println(logText)
        return logText
    }
    // ファイルのフルパスを生成
    fullPath := filepath.Join(dir, name)
    fmt.Println("file path:" + fullPath)
    // ファイルを作成
    file := CreateFile(fullPath)
    if file == nil {
        logText = "file create error."
        fmt.Println(logText)
        return logText
    }
    // ファイルのコピー
    _, err := file.Write(body)
    defer file.Close()
    if err != nil {
        logText = "file write error."
        fmt.Println(logText, err)
        return logText
    }
    upURL := config.File.DLURL + "/" + hash + "/" + name
    return fmt.Sprintf("<%s|%s>", upURL, upURL)
}

// ファイル取得処理
func GetFile(url string) []byte {
    req, _ := http.NewRequest("GET", url, nil)
    // パラメーターのセット
    req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.Slack.BotToken))
    fmt.Println(req.URL.String())
    // 実行
    client := &http.Client{}
    res, _ := client.Do(req)
    // ボディのセット
    resBody, _ := ioutil.ReadAll(res.Body)
    defer res.Body.Close()
    return resBody
}

// ハッシュ値の生成処理
func CreateHash(name string) (hash string) {
    sha1 := sha1.Sum([]byte(name))
    hash = fmt.Sprintf("%x", sha1)
    fmt.Println("hash:" + hash)
    return hash
}

// ディレクトリ作成処理
func CreateDir(path string) bool {
    _, err := os.Stat(path)
    if err == nil {
        fmt.Println("dir exists." + path)
        return true
    }
    if err := os.Mkdir(path, 0755); err != nil {
        fmt.Println("dir create error.", err)
        return false
    }
    return true
}

// ファイル作成処理
func CreateFile(path string) *os.File {
    _, err := os.Stat(path)
    if err == nil {
        fmt.Println("file is exists.", err)
        return nil
    }
    file, err := os.Create(path)
    if err != nil {
        fmt.Println("file create error.", err)
        return nil
    }
    return file
}

// ディスクの容量確認処理
func IsAllowance() bool {
    // dfコマンド実行（指定のファイルシステムのみ）
    cmdstr := fmt.Sprintf("df -k |grep %s | awk '{ print $4 }'", config.File.Fsys)
    out, _ := exec.Command("sh", "-c", cmdstr).Output()
    // 実行結果から改行をトリミング
    avStr := strings.TrimRight(string(out), "\n")
    text := fmt.Sprintf("df:%s remit:%d", avStr, config.File.Remit)
    fmt.Println(text)
    logger.DebugLog(logConf, text)
    // 実行結果から空き容量だけを抽出
    avail, _ := strconv.ParseInt(avStr, 10, 32)
    text = fmt.Sprintf("df1:%s df2:%d remit:%d", avStr, avail, config.File.Remit)
    fmt.Println(text)
    logger.DebugLog(logConf, text)
    // 空き容量のしきい値チェック
    if avail < config.File.Remit {
        return false
    }
    return true
}

// ディレクトリ内を再帰的に取得
func DirWalk(dir string) (path string) {
    files, err := ioutil.ReadDir(filepath.Join(config.File.Path, dir))
    if err != nil {
        panic(err)
    }
    for _, file := range files {
        if file.IsDir() {
            path = DirWalk(filepath.Join(dir, file.Name()))
            continue
        } else {
            path = filepath.Join((filepath.Base(dir)), file.Name())
        }
    }
    return path
}

// ファイル削除処理
func DelFile(target string) (result string) {
    // ターゲットが空かどうかチェック
    if target == "" {
        result = fmt.Sprintf("[dir name/file name] is empty.")
        fmt.Println(result)
        return result
    }
    // ファイルの存在確認
    fullPath := filepath.Join(config.File.Path, target)
    fInfo, err := os.Stat(fullPath)
    if err != nil {
        result = fmt.Sprintf("file not exists. %s", target)
        fmt.Println(result)
        return result
    }
    // ファイルかディレクトリかで分岐
    var dirPath string
    if fInfo.IsDir() {
        dirPath = fullPath
    } else {
        dirPath = filepath.Dir(fullPath)
    }
    fmt.Println(fmt.Sprintf("dir path:%s", dirPath))
    // ファイル削除
    if err := os.RemoveAll(dirPath); err != nil {
        result = fmt.Sprintf("failed to delete the file. %s", target)
        fmt.Println(result, err)
        return result
    }
    result = fmt.Sprintf("succesed to delete the file. %s", target)
    return result
}

```

Gitは[こちら](https://github.com/anabebe1020/uploader "uploader")です。

</div></details>

## サーバーの環境構築

<details><summary>サーバーの環境構築です。CLIにて行います。</summary><div>

### ディレクトリ作成

ファイルをアップロードする際の格納ディレクトリを作成します。

```
$ mkdir /xxx/xxx/xxx/download
```

### Webサーバー設定

今回はNginxを使用します。（既に別用途でサーバーが立ってた）
ServerApp用の設定を追記します。

```
# vim /etc/nginx/sites-available/xxx.xxx.co.jp

upstream slack-app-gateway {
    server localhost:10080; // 他と被らないポート番号
}

server {
    listen       443;
    server_name  xxx.xxx.co.jp;

    ssl on;
    ssl_certificate /xxx/xxx/fullchain.pem;
    ssl_certificate_key /xxx/xxx/privkey.pem;

    error_log /tmp/nginx.log debug;

    location ~ ^/file-uploader {
        proxy_pass http://slack-app-gateway;
        proxy_http_version 1.1;
    }

    location ~ ^/file-cmd {
        proxy_pass http://slack-app-gateway;
        proxy_http_version 1.1;
    }
}
```

Nginx設定ファイルを再読み込みします。

```
# systemctl reload nginx
```

サービスのユニットファイルを配置します。
FileUploader.serviceにはServerAppのフルパスを指定してください。

```
# cp -p file-uploader/service/FileUploader.service /etc/systemd/system/
# systemctl daemon-reload
```

サービスの自動起動を有効化し、起動します。

```
# systemctl enable FileUploader.service
# systemctl start FileUploader.service
```

</div></details>

## Slack APIの設定

<details><summary>Slack APIの設定です。専用のWebページにて行います。</summary><div>

### OAuth & Permissions

#### Bot Token Scopes

Slack Appの権限を設定します。

- channels:history

SlackAppがパブリックチャンネルのメッセージを読んだりするのに使用

- chat:write

SlackAppがチャットを送信するのに使用

- commands

SlackAppがSlashCommandを受け取るのに使用

- incoming-webhook

Slackのチャンネルに対しPOSTを送信するのに使用

- links:write

URLをリンクとして使用

### Event Subscriptions

#### Enable Events

Onにします。
ファイル共有のイベントをキャッチするために必要です。

#### Request URL

ServerAppでイベントをキャッチするURLを指定します。

#### Subscribe to bot events

どのイベントを受信できるようにするかの設定をします。

- file_shared

ファイルが共有された際のイベントを発行できる

- message.channels

メッセージが送信された際のイベントを発行できる

### Incoming Webhooks

#### Activate Incoming Webhooks

Onにします。
Slackのチャンネルに対しPOSTを送信するのに使用します。

### Slash Commands

Slack内で新たに使えるコマンドを作成します。

- /list

アップロード済みのファイルを一覧表示する。

| 項目 | 設定値 |
|:-|:-|
| Command | /list |
| Request URL | ServerAppでコマンドをキャッチするURL |
| Short Description | Displays list of uploaded files. |
| Usage Hint | なし |

- /del 

指定したファイルを削除する。

| 項目 | 設定値 |
|:-|:-|
| Command | /del |
| Request URL | ServerAppでコマンドをキャッチするURL |
| Short Description | Delete the specified file. |
| Usage Hint | [dir name/file name] |

</div></details>

# まとめ

大変でしたが、社内でまぁまぁ好評で使ってもらってます。
技術的には、私は普段開発ではフロントエンドでバックエンドは書かないので、いい勉強になりました。
goroutineやchannelも使ってみたいですね。改修しようかな。