GolangでSlackの技術的質問の投稿に回答がゼロだったらChatGPTが回答してくれるBotをつくった

## 概要

この記事では、Golangで開発したSlackボットについて簡単に説明します。
このボットは、技術的な質問が投稿された際に、回答がない場合にChatGPTによる解答を提供します。

ChatGPTAPIの使い方や、GolangからAPIを実行する方法が学べます。

GitHubは以下です。
https://github.com/Kiyo510/slack_reply_ChatGPT

## 環境
- Go 1.20
- Ubuntu 20.04

## SlackBotの準備
### Slackアプリの作成
こちらの記事がわかりやすかったです。
https://zenn.dev/mokomoka/articles/6d281d27aa344e#1.-bot%E3%81%AE%E4%BD%9C%E6%88%90

#### BotのScopeの設定
今回Botに与える権限は下記のような感じになります。(incoming-webhookはなくても良いかも)

| OAuth Scope       | 権限の解説                                                             |
|-------------------|---------------------------------------------------------------------------|
| channels:history  | ChatGPT Botが追加されたパブリックチャンネルのメッセージやその他のコンテンツを閲覧する |
| chat:write        | @Slack Reply From ChatGPTとしてメッセージを送信する                          |
| groups:history    | ChatGPT Botが追加されたプライベートチャンネルのメッセージやその他のコンテンツを閲覧する |
| im:history        | ChatGPT Botが追加されたダイレクトメッセージのメッセージやその他のコンテンツを閲覧する |
| incoming-webhook  | Slackの特定のチャンネルにメッセージを投稿する                                 |
| mpim:history      | ChatGPT Botが追加されたグループダイレクトメッセージのメッセージやその他のコンテンツを閲覧する |



### APIトークンの取得
**`OAuth Tokens for Your Workspace`** の下にある **`Bot User OAuth Token`** という項目にSlackAPIで使うトークンが記述されています。
SlackAPIをBot Userとして叩くときに利用するのでメモしといてください。
また、他でもさんざん書かれていると思いますが、APIトークンは絶対に外部に漏らさないよう厳重に管理してください。
**ソースコードごと貼り付けてChatGPTなんかに渡すことのないように!!**
(参考：[ChatGPT一時障害　他人の会話の一部や決済情報見られる状態に（朝日新聞デジタル） - Yahoo!ニュース](https://news.yahoo.co.jp/articles/7bda2e84966e718fb6e5b318d17c880608311f8a))
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/632678/c73d4ea5-1fa4-b16d-c878-9a4114191ef5.png)



## ChatGPTとAPI利用のための準備
こちらについては下記記事がわかりやすいので、ご参考下さい。
https://qiita.com/mikito/items/b69f38c54b362c20e9e6


## GolangでのBot開発
### ソースコード
今回機能は使い捨てのため、main.goに殴り書きしています。(ChatGPTの$18.00分の無料クレジットは私のアカウント上だと期限が5/1のため) 

```golang
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/joho/godotenv"
)

const (
	slackApiBaseUrl = "https://slack.com/api/"
	chatGptApiUrl   = "https://api.openai.com/v1/chat/completions"
	AnswerLimit     = 10
)

var slackBotToken string
var chatGptApiKey string

type SlackMessage struct {
	Type       string `json:"type"`
	User       string `json:"user"`
	Text       string `json:"text"`
	Ts         string `json:"ts"`
	ThreadTs   string `json:"thread_ts"`
	ReplyCount int    `json:"reply_count"`
}

type SlackConversationsHistoryResponse struct {
	Ok       bool           `json:"ok"`
	Messages []SlackMessage `json:"messages"`
	Error    string         `json:"error"`
	Needed   string         `json:"needed"`
}

type SlackPostMessageResponse struct {
	Ok     bool   `json:"ok"`
	Error  string `json:"error"`
	Needed string `json:"needed"`
}

type ChatMessage struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type ChatGPTPayLoad struct {
	Model     string        `json:"model"`
	Messages  []ChatMessage `json:"messages"`
	MaxTokens int           `json:"max_tokens"`
}

type ChatGptResponse struct {
	Choices []struct {
		Message struct {
			Role    string `json:"role"`
			Content string `json:"content"`
		} `json:"message"`
	} `json:"choices"`
}

func init() {
	err := godotenv.Load(".env")
	if err != nil {
		fmt.Println("Error loading .env file")
		return
	}
}

func main() {
	slackBotToken = os.Getenv("SLACK_BOT_TOKEN")
	chatGptApiKey = os.Getenv("CHAT_GPT_API_KEY")
	channelId := os.Getenv("SLACK_CHANNEL_ID")

	messages, err := fetchSlackMessages(channelId)
	if err != nil {
		fmt.Println("Error fetching slack message:", err)
		return
	}

	sort.Slice(messages, func(i, j int) bool {
		tsi, err := strconv.ParseFloat(messages[i].Ts, 64)
		if err != nil {
			return false
		}

		tsj, err := strconv.ParseFloat(messages[j].Ts, 64)
		if err != nil {
			return false
		}

		return tsi < tsj
	})

	var filterMessages []SlackMessage
	for _, message := range messages {
		if isQuestion(message.Text) && message.ReplyCount == 0 {
			filterMessages = append(filterMessages, message)
		}
	}

	for i, message := range filterMessages {
		if i > AnswerLimit {
			break
		}

		resp, err := sendToChatGpt(message.Text)
		if err != nil {
			fmt.Println("Error sending message to ChatGPT:", err)
			continue
		}

		respWithMention := fmt.Sprintf("<@%s>\n%s", message.User, resp)
		err = postToSlackThread(channelId, message.ThreadTs, respWithMention)
		if err != nil {
			fmt.Println("Error posting to Slack thread:", err)
			return
		}

		fmt.Println("Post Slack Thread Done")
	}
}

func fetchSlackMessages(channelId string) ([]SlackMessage, error) {
	now := time.Now()
	midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
	url := fmt.Sprintf("%sconversations.history?channel=%s&oldest=%d", slackApiBaseUrl, channelId, midnight.Unix())

	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", slackBotToken))

	client := &http.Client{Timeout: time.Second * 10}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}

	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	var apiResponse SlackConversationsHistoryResponse
	err = json.Unmarshal(body, &apiResponse)
	if err != nil {
		return nil, err
	}

	if !apiResponse.Ok {
		return nil, fmt.Errorf("slack API error: %s, needed: %s", apiResponse.Error, apiResponse.Needed)
	}

	return apiResponse.Messages, nil
}

func isQuestion(s string) bool {
	return strings.Contains(s, "質問です")
}

func postToSlackThread(channelId, threadTs, message string) error {
	url := fmt.Sprintf("%schat.postMessage", slackApiBaseUrl)

	requestData := map[string]interface{}{
		"token":     slackBotToken,
		"channel":   channelId,
		"text":      message,
		"thread_ts": threadTs,
	}

	jsonData, err := json.Marshal(requestData)
	if err != nil {
		return err
	}

	req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
	if err != nil {
		return err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", slackBotToken))

	client := &http.Client{Timeout: time.Second * 10}
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return err
	}

	var apiResponse SlackPostMessageResponse
	err = json.Unmarshal(body, &apiResponse)
	if err != nil {
		return err
	}

	if !apiResponse.Ok {
		return fmt.Errorf("slack API error: %s, needed: %s", apiResponse.Error, apiResponse.Needed)
	}

	return nil
}

func sendToChatGpt(prompt string) (string, error) {
	message := []ChatMessage{
		{
			Role:    "user",
			Content: prompt,
		},
	}

	requestData := ChatGPTPayLoad{
		Model:     "gpt-3.5-turbo",
		Messages:  message,
		MaxTokens: 1000,
	}

	jsonData, err := json.Marshal(requestData)
	if err != nil {
		return "", err
	}

	req, err := http.NewRequest("POST", chatGptApiUrl, bytes.NewBuffer(jsonData))
	if err != nil {
		return "", err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", chatGptApiKey))

	client := &http.Client{
		Timeout: time.Second * 10,
	}

	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}

	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}

	var apiResponse ChatGptResponse

	err = json.Unmarshal(body, &apiResponse)
	if err != nil {
		return "", err
	}

	if len(apiResponse.Choices) == 0 {
		return "", fmt.Errorf("no response from ChatGPT")
	}

	return apiResponse.Choices[0].Message.Content, nil
}
```
### 解説
まず、外部ライブラリや環境変数をインポートします。
```golang
import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/joho/godotenv"
)
```

必要な定数と構造体を定義します。これにより、SlackとChatGPT APIとの通信で使用されるデータ構造が定義されます。
AnswerLimitは一日の最大回答数です。

```golang
const (
	slackApiBaseUrl = "https://slack.com/api/"
	chatGptApiUrl   = "https://api.openai.com/v1/chat/completions"
	AnswerLimit     = 10
)
...
```

init関数で、.envファイルから環境変数を読み込みます。
Goのinit関数はmain関数より先に実行される特別な関数です。

```golang
func init() {
	err := godotenv.Load(".env")
	if err != nil {
		fmt.Println("Error loading .env file")
		return
	}
}
```


main関数では、環境変数からSlackとChatGPT APIのトークンを取得し、対象のSlackチャンネルのIDも取得します。
次に、Slackチャンネルからメッセージを取得し、それらを時系列順に並べ替えます。
その後、質問であり&&回答がないメッセージをフィルタリングし、それらの質問をChatGPTAPIへ渡して回答を生成してスレッドに投稿します。

```golang
func main() {
	slackBotToken = os.Getenv("SLACK_BOT_TOKEN")
	chatGptApiKey = os.Getenv("CHAT_GPT_API_KEY")
	channelId := os.Getenv("SLACK_CHANNEL_ID")

	messages, err := fetchSlackMessages(channelId)
	if err != nil {
		fmt.Println("Error fetching slack message:", err)
		return
	}

	sort.Slice(messages, func(i, j int) bool {
		tsi, err := strconv.ParseFloat(messages[i].Ts, 64)
		if err != nil {
			return false
		}

		tsj, err := strconv.ParseFloat(messages[j].Ts, 64)
		if err != nil {
			return false
		}

		return tsi < tsj
	})

	var filterMessages []SlackMessage
	for _, message := range messages {
		if isQuestion(message.Text) && message.ReplyCount == 0 {
			filterMessages = append(filterMessages, message)
		}
	}

	for i, message := range filterMessages {
		if i > AnswerLimit {
			break
		}

		resp, err := sendToChatGpt(message.Text)
		if err != nil {
			fmt.Println("Error sending message to ChatGPT:", err)
			continue
		}

		respWithMention := fmt.Sprintf("<@%s>\n%s", message.User, resp)
		err = postToSlackThread(channelId, message.ThreadTs, respWithMention)
		if err != nil {
			fmt.Println("Error posting to Slack thread:", err)
			return
		}

		fmt.Println("Post Slack Thread Done")
	}
} 
```

fetchSlackMessages関数では、Slack APIを使って指定されたチャンネルのメッセージ履歴を取得します。

```golang
func fetchSlackMessages(channelId string) ([]SlackMessage, error) {
	now := time.Now()
	midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
	url := fmt.Sprintf("%sconversations.history?channel=%s&oldest=%d", slackApiBaseUrl, channelId, midnight.Unix())

	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", slackBotToken))

	client := &http.Client{Timeout: time.Second * 10}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}

	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	var apiResponse SlackConversationsHistoryResponse
	err = json.Unmarshal(body, &apiResponse)
	if err != nil {
		return nil, err
	}

	if !apiResponse.Ok {
		return nil, fmt.Errorf("slack API error: %s, needed: %s", apiResponse.Error, apiResponse.Needed)
	}

	return apiResponse.Messages, nil
```

isQuestion関数は、Slackに投稿されたメッセージが質問であるかどうかを判定するための簡単なヘルパー関数です。
ここは各自適当に定義します。

```golang
func isQuestion(s string) bool {
    return strings.Contains(s, "質問です")
}
```

sendToChatGpt関数では、質問をChatGPT APIに送信し、生成された回答を受け取ります。

```golang
func sendToChatGpt(prompt string) (string, error) {
	message := []ChatMessage{
		{
			Role:    "user",
			Content: prompt,
		},
	}

	requestData := ChatGPTPayLoad{
		Model:     "gpt-3.5-turbo",
		Messages:  message,
		MaxTokens: 1000,
	}

	jsonData, err := json.Marshal(requestData)
	if err != nil {
		return "", err
	}

	req, err := http.NewRequest("POST", chatGptApiUrl, bytes.NewBuffer(jsonData))
	if err != nil {
		return "", err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", chatGptApiKey))

	client := &http.Client{
		Timeout: time.Second * 10,
	}

	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}

	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}

	var apiResponse ChatGptResponse

	err = json.Unmarshal(body, &apiResponse)
	if err != nil {
		return "", err
	}

	if len(apiResponse.Choices) == 0 {
		return "", fmt.Errorf("no response from ChatGPT")
	}

	return apiResponse.Choices[0].Message.Content, nil
}
```

最終的に、postToSlackThread関数で、ChatGPT-APIからのレスポンス(生成された回答)を指定されたスレッドに投稿します。[^1]

```golang
func postToSlackThread(channelId, threadTs, message string) error {
	url := fmt.Sprintf("%schat.postMessage", slackApiBaseUrl)

	requestData := map[string]interface{}{
		"token":     slackBotToken,
		"channel":   channelId,
		"text":      message,
		"thread_ts": threadTs,
	}

	jsonData, err := json.Marshal(requestData)
	if err != nil {
		return err
	}

	req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
	if err != nil {
		return err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", slackBotToken))

	client := &http.Client{Timeout: time.Second * 10}
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return err
	}

	var apiResponse SlackPostMessageResponse
	err = json.Unmarshal(body, &apiResponse)
	if err != nil {
		return err
	}

	if !apiResponse.Ok {
		return fmt.Errorf("slack API error: %s, needed: %s", apiResponse.Error, apiResponse.Needed)
	}

	return nil
}
```

## 今後実装したいこと
- 同じ質問が重複(二重投稿しちゃったとか)していたらひとつにまとめてChatGPT APIにPOSTする機能。
     - API実行時になんらかの不具合で失敗することがあるので、その場合にリトライさせる機能(3回ぐらいで良さそう)
    - とくにChatGPTAPIはたまにネットワークエラーとかあるので、その辺のハンドリングをする
        - だいたいこういうのはリトライ用のパッケージがあると思うので、それを使うとかでできそう
- エラーで実行できなかったらSlackへエラーメッセージを通知する
    - 今単純に標準出力してるだけなので、ファイルにロギングするようにしてそれをSlackへ投げるとかで実現できそう？？要調査
- 複数チャンネル対応
    - 現状の実装だと、ひとつのチャンネルにしか対応していない。
        - ループさせて実行するだけじゃつまらないので、gorutineをつかってマルチスレッド処理をやってみる。

## 最後まで読んでいただきありがとうございました!
なにか不明点などあればコメントいただけますと幸いです。
最後まで読んでいただき、ありがとうございました。

[^1]: スレッドにリプライを投稿するためには各スレッドのタイムスタンプの値をリクエストで指定する必要があります。Slackのチャンネルのメッセージ一覧取得時のレスポンスに`"ts": "1678815524.119619"`のようなものがあるので、これをSlackのchat.postMessageAPIへ渡してリクエストを投げると指定したスレッドに回答できます。しかし、試した感じだとBotの場合は特定のスレッドを指定してもチャンネルに直接投稿されてしまうようです。(この辺、わかる方いたら教えてください。)