LoginSignup
2
1

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-03-27

概要

この記事では、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!ニュース)
image.png

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をつかってマルチスレッド処理をやってみる。

最後まで読んでいただきありがとうございました!

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

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

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1