[Go]Slackで古い投稿を一括削除する

この記事は [DENSO アドベントカレンダー 2021](https://qiita.com/advent-calendar/2021/denso) の10日目の記事です。

年末大掃除の時期となりました。古くなったSlackの投稿を一括削除して、気持ちよく新年を迎えるための機能をGo言語で実装しました。

# 概要
SlackのAPIトークンと、Slackチャンネル名を設定してプログラムを起動します。日付を入力し確定すると、入力した日付より古い投稿を削除します。

```shell:
$ ./cleaner
This program delete SLACK messages older than the date you enter.
Enter date in the format like 2006/01/02:  2021/11/28
Are you sure you want to delete messages of Channels ["memo" "aws-cost"] older than 2021/11/28? (Y/n) >y
start.
Deleted. channel: memo, timestamp: 2021-11-27 19:31:52 +0900 JST
Deleted. channel: memo, timestamp: 2021-11-27 19:31:36 +0900 JST
Deleted. channel: memo, timestamp: 2021-11-27 19:31:27 +0900 JST
Deleted. channel: aws-cost, timestamp: 2021-11-27 09:00:13 +0900 JST
```

# 事前準備
SlackのAPIトークンを発行します。手順は割愛するので公式ドキュメントや他の記事を参照してください。スコープ設定に関して補足すると、今回は [conversations.list](https://api.slack.com/methods/conversations.list)、[conversations.history](https://api.slack.com/methods/conversations.history)、[chat.delete](https://api.slack.com/methods/chat.delete) のAPIを使用するので、リンク先の公式ドキュメントで必要なスコープを確認してください。


# 実行環境
macOSで動作を確認しています。

```
$ go version  
go version go1.17.3 darwin/amd64
```

# 実装
パッケージは2つのファイルで構成しています。

```
cleaner
├── main.go
└── slack.go
```

それぞれの内容です。

```go:main.go
package main

import (
	"bufio"
	"errors"
	"fmt"
	"log"
	"os"
	"strings"
	"time"
)

const (
	SLACK_API_TOKEN = "xxxx-xxxx"
	CHANNEL_LIST    = "memo,aws-cost"
)

const shortForm = "2006/01/02"

var channelList []string

func init() {
	channelList = strings.Split(CHANNEL_LIST, ",")
}

func getTargetTime() (*time.Time, error) {
	fmt.Println("This program delete SLACK messages older than the date you enter.")
	fmt.Printf("Enter date in the format like %s:  ", shortForm)

	scanner := bufio.NewScanner(os.Stdin)
	scanner.Scan()
	input := scanner.Text()

	// default: delete messages older than 1 month
	if input == "" {
		t := time.Now().AddDate(0, -1, 0)

		return &t, nil
	}

	t, err := time.Parse(shortForm, input)
	if err != nil {
		return nil, fmt.Errorf("failed to parse timestamp: %w", err)
	}

	return &t, nil
}

func confirm(timestamp string, channelList []string) error {
	fmt.Printf("Are you sure you want to delete messages of Channels %q older than %s? (Y/n) >", channelList, timestamp)

	scanner := bufio.NewScanner(os.Stdin)
	scanner.Scan()

	switch strings.ToLower(scanner.Text()) {
	case "y", "yes":
		fmt.Println("start.")
	default:
		return errors.New("aborting the process")
	}

	return nil
}

func main() {
	c := NewClient(SLACK_API_TOKEN)

	channelMap, err := c.GetChannelMap(channelList)
	if err != nil {
		log.Fatal(err)
	}

	targetTimestamp, err := getTargetTime()
	if err != nil {
		log.Fatal(err)
	}

	if err := confirm(targetTimestamp.Format(shortForm), channelList); err != nil {
		log.Fatal(err)
	}

	if err := c.Delete(*targetTimestamp, channelMap); err != nil {
		log.Fatal(err)
	}
}


```

```go:slack.go
package main

import (
	"fmt"
	"strconv"
	"strings"
	"time"

	"github.com/slack-go/slack"
)

type (
	ChannelName string
	ChannelID   string
	ChannelMap  map[ChannelName]ChannelID
)

type Client struct {
	*slack.Client
}

func NewClient(token string) *Client {
	return &Client{
		slack.New(token),
	}
}

func (c *Client) GetChannelMap(targetChList []string) (ChannelMap, error) {
	result := make(ChannelMap)

	chList, _, err := c.GetConversations(&slack.GetConversationsParameters{})
	if err != nil {
		return nil, fmt.Errorf("failed to get conversations: %w", err)
	}

	for _, targetCh := range targetChList {
		chID, err := findChannelID(chList, targetCh)
		if err != nil {
			return nil, err
		}

		result[ChannelName(targetCh)] = chID
	}

	return result, nil
}

func findChannelID(chList []slack.Channel, targetCh string) (ChannelID, error) {
	for _, ch := range chList {
		if ch.GroupConversation.Name == targetCh {
			return ChannelID(ch.GroupConversation.Conversation.ID), nil
		}
	}

	return "", fmt.Errorf("failed to find channel: \"%s\"", targetCh)
}

func (c *Client) Delete(timestamp time.Time, channelMap ChannelMap) error {
	for channelName, channelID := range channelMap {
		if err := c.delete(timestamp, channelName, channelID, ""); err != nil {
			return err
		}
	}

	return nil
}

func (c *Client) delete(timestamp time.Time, channelName ChannelName, channelID ChannelID, nextCursor string) error {
	params := &slack.GetConversationHistoryParameters{
		ChannelID: string(channelID),
		Limit:     100,
		Latest:    strconv.FormatInt(timestamp.Unix(), 10) + ".000000",
		Cursor:    nextCursor,
	}

	res, err := c.GetConversationHistory(params)
	if err != nil {
		return fmt.Errorf("failed to get conversation history: %w", err)
	}

	for _, msg := range res.Messages {
		_, t, err := c.DeleteMessage(string(channelID), msg.Timestamp)
		if err != nil {
			return fmt.Errorf("failed to delete message: %w", err)
		}

		unixTime, err := strconv.ParseInt(strings.Split(t, ".")[0], 10, 64)
		if err != nil {
			return fmt.Errorf("failed to parse timestamp: %w", err)
		}

		fmt.Printf("Deleted. channel: %+v, timestamp: %+v\n", channelName, time.Unix(unixTime, 0).Local())
		time.Sleep(1200 * time.Millisecond) // chat.delete API is Tier3. Rate limit is 50+ per minute.
	}

	if res.HasMore {
		if err := c.delete(timestamp, channelName, channelID, res.ResponseMetaData.NextCursor); err != nil {
			return err
		}
	}

	return nil
}
```

<br>
`main.go`で定義しているAPIトークンと、削除対象のチャンネル（カンマ区切り、空白なし）を編集して使用します。

```go:main.go
SLACK_API_TOKEN = "xxxx-xxxx"
CHANNEL_LIST    = "memo,aws-cost"
```

編集が済んだら`go mod init cleaner && go mod tidy && go build`コマンドを実行します。これで実行ファイル`cleaner`ができます。


# 処理の説明
大まかな流れとして、次の処理をします。
  
* 対象のチャンネルが存在するか確認し、チャンネルIDを取得
* ユーザの入力した日付をチェック（入力がなければデフォルトで1ヶ月前を設定）
* 削除についてユーザに最終確認
* 対象日付より古いメッセージ情報を取得し削除


### 実装時の注意点
APIの制限に対応して実装時に注意した点が２つあります。

[conversations.history](https://api.slack.com/methods/conversations.history) APIを使用して、対象の日付より投稿日時が古いメッセージの情報を取得します。このAPIでは１回のレスポンスで最大1000件のメッセージ情報を取得できますが、公式では200件以下を推奨しており今回の実装では100件を設定しています。

```go:main.go
params := &slack.GetConversationHistoryParameters{
    ChannelID: string(channelID),
    Limit:     100,
```

削除対象が100件を超えるかどうかは、APIレスポンスの`HasMore`フィールドで判定します。この値と、次の対象メッセージへの参照を示す`ResponseMetaData.NextCursor`フィールドの値を用いて再帰処理しています。

```go:main.go
if res.HasMore {
	if err := c.delete(timestamp, channelName, channelID, res.ResponseMetaData.NextCursor); err != nil {
		return err
	}
}
```

<br>


もう一つはAPIの [rate limit](https://api.slack.com/docs/rate-limits) に関する注意です。今回のケースでは１件毎にメッセージを削除する [chat.delete](https://api.slack.com/methods/chat.delete) APIを呼び出すため、制限にかからないようスリープ処理しています。

```go:slack.go
time.Sleep(1200 * time.Millisecond) // chat.delete API is Tier3. Rate limit is 50+ per minute.
```

# 終わりに
リアルタイムでメッセージが消える様子が、記憶領域を破損してデータが失われていくような演出っぽくてちょっと好きだったりします。事情を知らない人が見るとびっくりするので、作業時は一声かけたほうが良いでしょう。**指定するチャンネルを間違えると一大事ですので注意してください。**