こんにちは。
営業やプリセールスなんかやってる人は、社外の方へ資料をメールで送ることなんて良くありますよね。しかし動画など大きいサイズのデータをメールで送ろうとするとサイズオーバーで添付できない!で、仕方なく外部のファイルアップローダーを使うも、毎回これはめんどくさい、、、
というあるあるを見事解決してやりました。
先にまずどんなことができるか、その次に作った手順なんかを記載します。
何ができるの?
Slackの特定のチャンネルにファイルを送信すると、自社のファイルサーバーにアップロードされ、そのURLが返信される という仕組みです。
便利でしょ。一瞬ですよ。
(その部分をキャプチャ撮ってGIFアニメ作って貼ろうかと思ったんですが、そちらは一瞬でできそうになかったので私は諦めたんです。)
本プロジェクトの機能はざっくりと3つほどあります。
3つの機能
【メイン】アップロード機能
上にも書きましたが、特定のチャンネルにファイルを送信すると、自社のファイルサーバーにアップロードされ、そのURLが返信されます。
また、アップロード時にサーバーのディスクが逼迫している場合、アップロードせずにエラーを返します。
【オプション】ファイル一覧表示機能
チャンネル内で/list
というコマンドを打つと、今アップロードされているファイルの一覧を返します。
【オプション】ファイル削除機能
チャンネル内で/del [dir name/file name]
というコマンドを打つと、指定したファイル削除を削除します。
また、アップロードから一定期間経過したファイルは自動削除します。
という感じです。便利でしょ。(2回目)
作成手順
Slack APIの設定、サーバーの環境構築、サーバーアプリの開発、の3つの作業が必要です。
便宜上、Slack側のアプリをSlackApp、サーバーアプリをServerAppと呼びます。
サーバーアプリの開発
サーバーアプリの開発内容です。VSCodeからRemoteSSHにて行います。
プロジェクト構成です。
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にてサービスを起動します。
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のパラメーターを一致させて読み込みます。
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の送受信を行います。
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に関わる処理を行います。
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)
}
ファイル処理です。
ディレクトリやファイルを扱います。
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も使ってみたいですね。改修しようかな。