LoginSignup
0
0

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-03-17

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

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

何ができるの?

Slackの特定のチャンネルにファイルを送信すると、自社のファイルサーバーにアップロードされ、そのURLが返信される という仕組みです。
便利でしょ。一瞬ですよ。
スクリーンショット 2021-03-17 20.27.52.png
(その部分をキャプチャ撮ってGIFアニメ作って貼ろうかと思ったんですが、そちらは一瞬でできそうになかったので私は諦めたんです。)

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

3つの機能

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

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

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

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

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

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

という感じです。便利でしょ。(2回目)

作成手順

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

サーバーアプリの開発

サーバーアプリの開発内容です。VSCodeからRemoteSSHにて行います。

プロジェクト構成です。

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にてサービスを起動します。

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のパラメーターを一致させて読み込みます。

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の送受信を行います。

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に関わる処理を行います。

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)
}

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

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はこちらです。

サーバーの環境構築

サーバーの環境構築です。CLIにて行います。

ディレクトリ作成

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

$ 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

Slack APIの設定

Slack APIの設定です。専用のWebページにて行います。

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]

まとめ

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0