この記事は DENSO アドベントカレンダー 2021 の10日目の記事です。
年末大掃除の時期となりました。古くなったSlackの投稿を一括削除して、気持ちよく新年を迎えるための機能をGo言語で実装しました。
概要
SlackのAPIトークンと、Slackチャンネル名を設定してプログラムを起動します。日付を入力し確定すると、入力した日付より古い投稿を削除します。
$ ./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、conversations.history、chat.delete のAPIを使用するので、リンク先の公式ドキュメントで必要なスコープを確認してください。
実行環境
macOSで動作を確認しています。
$ go version
go version go1.17.3 darwin/amd64
実装
パッケージは2つのファイルで構成しています。
cleaner
├── main.go
└── slack.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)
}
}
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
}
`main.go`で定義しているAPIトークンと、削除対象のチャンネル(カンマ区切り、空白なし)を編集して使用します。
SLACK_API_TOKEN = "xxxx-xxxx"
CHANNEL_LIST = "memo,aws-cost"
編集が済んだらgo mod init cleaner && go mod tidy && go build
コマンドを実行します。これで実行ファイルcleaner
ができます。
処理の説明
大まかな流れとして、次の処理をします。
- 対象のチャンネルが存在するか確認し、チャンネルIDを取得
- ユーザの入力した日付をチェック(入力がなければデフォルトで1ヶ月前を設定)
- 削除についてユーザに最終確認
- 対象日付より古いメッセージ情報を取得し削除
実装時の注意点
APIの制限に対応して実装時に注意した点が2つあります。
conversations.history APIを使用して、対象の日付より投稿日時が古いメッセージの情報を取得します。このAPIでは1回のレスポンスで最大1000件のメッセージ情報を取得できますが、公式では200件以下を推奨しており今回の実装では100件を設定しています。
params := &slack.GetConversationHistoryParameters{
ChannelID: string(channelID),
Limit: 100,
削除対象が100件を超えるかどうかは、APIレスポンスのHasMore
フィールドで判定します。この値と、次の対象メッセージへの参照を示すResponseMetaData.NextCursor
フィールドの値を用いて再帰処理しています。
if res.HasMore {
if err := c.delete(timestamp, channelName, channelID, res.ResponseMetaData.NextCursor); err != nil {
return err
}
}
もう一つはAPIの rate limit に関する注意です。今回のケースでは1件毎にメッセージを削除する chat.delete APIを呼び出すため、制限にかからないようスリープ処理しています。
time.Sleep(1200 * time.Millisecond) // chat.delete API is Tier3. Rate limit is 50+ per minute.
終わりに
リアルタイムでメッセージが消える様子が、記憶領域を破損してデータが失われていくような演出っぽくてちょっと好きだったりします。事情を知らない人が見るとびっくりするので、作業時は一声かけたほうが良いでしょう。指定するチャンネルを間違えると一大事ですので注意してください。