概要
この記事では、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!ニュース)
ChatGPTとAPI利用のための準備
こちらについては下記記事がわかりやすいので、ご参考下さい。
https://qiita.com/mikito/items/b69f38c54b362c20e9e6
GolangでのBot開発
ソースコード
今回機能は使い捨てのため、main.goに殴り書きしています。(ChatGPTの$18.00分の無料クレジットは私のアカウント上だと期限が5/1のため)
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
}
解説
まず、外部ライブラリや環境変数をインポートします。
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
必要な定数と構造体を定義します。これにより、SlackとChatGPT APIとの通信で使用されるデータ構造が定義されます。
AnswerLimitは一日の最大回答数です。
const (
slackApiBaseUrl = "https://slack.com/api/"
chatGptApiUrl = "https://api.openai.com/v1/chat/completions"
AnswerLimit = 10
)
...
init関数で、.envファイルから環境変数を読み込みます。
Goのinit関数はmain関数より先に実行される特別な関数です。
func init() {
err := godotenv.Load(".env")
if err != nil {
fmt.Println("Error loading .env file")
return
}
}
main関数では、環境変数からSlackとChatGPT APIのトークンを取得し、対象のSlackチャンネルのIDも取得します。
次に、Slackチャンネルからメッセージを取得し、それらを時系列順に並べ替えます。
その後、質問であり&&回答がないメッセージをフィルタリングし、それらの質問をChatGPTAPIへ渡して回答を生成してスレッドに投稿します。
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を使って指定されたチャンネルのメッセージ履歴を取得します。
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に投稿されたメッセージが質問であるかどうかを判定するための簡単なヘルパー関数です。
ここは各自適当に定義します。
func isQuestion(s string) bool {
return strings.Contains(s, "質問です")
}
sendToChatGpt関数では、質問をChatGPT APIに送信し、生成された回答を受け取ります。
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
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をつかってマルチスレッド処理をやってみる。
- 現状の実装だと、ひとつのチャンネルにしか対応していない。
最後まで読んでいただきありがとうございました!
なにか不明点などあればコメントいただけますと幸いです。
最後まで読んでいただき、ありがとうございました。
-
スレッドにリプライを投稿するためには各スレッドのタイムスタンプの値をリクエストで指定する必要があります。Slackのチャンネルのメッセージ一覧取得時のレスポンスに
"ts": "1678815524.119619"
のようなものがあるので、これをSlackのchat.postMessageAPIへ渡してリクエストを投げると指定したスレッドに回答できます。しかし、試した感じだとBotの場合は特定のスレッドを指定してもチャンネルに直接投稿されてしまうようです。(この辺、わかる方いたら教えてください。) ↩