diff --git a/Makefile b/Makefile index ee23684..4fe5f36 100644 --- a/Makefile +++ b/Makefile @@ -7,4 +7,4 @@ build: go build clean: - rm chatgpt-telegram + rm chatgpt-discord diff --git a/README.md b/README.md index ec4fddf..a90ecad 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,22 @@ -# ChatGPT-bot +# ChatGPT Discord Bot -> Interact with ChatGPT - -Go CLI to fuels a Telegram bot that lets you interact with [ChatGPT](https://openai.com/blog/chatgpt/), a large language model trained by OpenAI. +Go CLI to power a Discord bot letting you interact with [ChatGPT](https://openai.com/blog/chatgpt/), a large language model trained by OpenAI, collaboratively in Discord servers or privately in your DMs. ## Installation -Download the file corresponding to your OS in the [releases page](https://github.com/m1guelpf/chatgpt-telegram/releases/latest): +Download the file corresponding to your OS in the [releases page](https://github.com/m1guelpf/chatgpt-discord/releases/latest): -- `chatgpt-telegram-Darwin-amd64`: macOS (Intel) -- `chatgpt-telegram-Darwin-arm64`: macOS (M1) -- `chatgpt-telegram-Linux-amd64`: Linux -- `chatgpt-telegram-Linux-arm64`: Linux (ARM) -- `chatgpt-telegram-Win-amd64`: Windows +- `chatgpt-discord-Darwin-amd64`: macOS (Intel) +- `chatgpt-discord-Darwin-arm64`: macOS (M1) +- `chatgpt-discord-Linux-amd64`: Linux +- `chatgpt-discord-Linux-arm64`: Linux (ARM) +- `chatgpt-discord-Win-amd64`: Windows -After you download the file, extract it into a folder and open the `env.example` file with a text editor and fill in your credentials. You'll need your bot token, which you can find [here](https://core.telegram.org/bots/tutorial#obtain-your-bot-token), and optionally your telegram id, which you can find by DMing `@userinfobot` on Telegram. Save the file, and rename it to `.env`. +After you download the file, extract it into a folder and open the `env.example` file with a text editor and fill in your credentials. You'll need your bot token, which you can find [here](https://www.writebots.com/discord-bot-token/), and optionally (if you want to prevent anyone else from using the bot) your Discord username. Save the file, and rename it to `.env`. > **Note** Make sure you rename the file to _exactly_ `.env`! The program won't work otherwise. -Finally, open the terminal in your computer (if you're on windows, look for `PowerShell`), navigate to the path you extracted the above file (you can use `cd dirname` to navigate to a directory, ask ChatGPT if you need more assistance 😉) and run `./chatgpt-telegram`. +Finally, open the terminal in your computer (if you're on windows, look for `PowerShell`), navigate to the path you extracted the above file (you can use `cd dirname` to navigate to a directory, ask ChatGPT if you need more assistance 😉) and run `./chatgpt-discord`. ## Authentication diff --git a/env.example b/env.example index 47324e9..00f762f 100644 --- a/env.example +++ b/env.example @@ -1,2 +1,2 @@ -TELEGRAM_ID= -TELEGRAM_TOKEN= +DISCORD_TOKEN= +DISCORD_USERNAME= diff --git a/go.mod b/go.mod index 586700e..4292c77 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ -module github.com/m1guelpf/chatgpt-telegram +module github.com/m1guelpf/chatgpt-discord go 1.19 require ( - github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/bwmarrin/discordgo v0.26.1 github.com/google/uuid v1.3.0 github.com/joho/godotenv v1.4.0 github.com/launchdarkly/eventsource v1.7.1 @@ -15,6 +15,7 @@ require ( github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-stack/stack v1.8.1 // indirect + github.com/gorilla/websocket v1.4.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/magiconair/properties v1.8.6 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect @@ -25,6 +26,7 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.1 // indirect + golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect golang.org/x/sys v0.0.0-20220908164124-27713097b956 // indirect golang.org/x/text v0.4.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index f45b846..cdadbb9 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/bwmarrin/discordgo v0.26.1 h1:AIrM+g3cl+iYBr4yBxCBp9tD9jR3K7upEjl0d89FRkE= +github.com/bwmarrin/discordgo v0.26.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -65,8 +67,6 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= -github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= -github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -124,6 +124,7 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/h2non/filetype v1.1.1/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -203,6 +204,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM= +golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= diff --git a/main.go b/main.go index 2431a64..64787d4 100644 --- a/main.go +++ b/main.go @@ -5,17 +5,19 @@ import ( "log" "os" "os/signal" - "strconv" + "strings" "syscall" "time" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/bwmarrin/discordgo" "github.com/joho/godotenv" - "github.com/m1guelpf/chatgpt-telegram/src/chatgpt" - "github.com/m1guelpf/chatgpt-telegram/src/config" - "github.com/m1guelpf/chatgpt-telegram/src/markdown" - "github.com/m1guelpf/chatgpt-telegram/src/ratelimit" - "github.com/m1guelpf/chatgpt-telegram/src/session" + "github.com/m1guelpf/chatgpt-discord/src/auth" + "github.com/m1guelpf/chatgpt-discord/src/chatgpt" + "github.com/m1guelpf/chatgpt-discord/src/config" + "github.com/m1guelpf/chatgpt-discord/src/markdown" + "github.com/m1guelpf/chatgpt-discord/src/ratelimit" + "github.com/m1guelpf/chatgpt-discord/src/ref" + "github.com/m1guelpf/chatgpt-discord/src/session" ) type Conversation struct { @@ -49,138 +51,159 @@ func main() { log.Fatalf("Couldn't load .env file: %v", err) } - bot, err := tgbotapi.NewBotAPI(os.Getenv("TELEGRAM_TOKEN")) + discord, err := discordgo.New("Bot " + os.Getenv("DISCORD_TOKEN")) if err != nil { - log.Fatalf("Couldn't start Telegram bot: %v", err) + log.Fatalf("Couldn't start Discord bot: %v", err) } c := make(chan os.Signal, 2) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { <-c - bot.StopReceivingUpdates() + discord.Close() os.Exit(0) }() - updateConfig := tgbotapi.NewUpdate(0) - updateConfig.Timeout = 30 - updates := bot.GetUpdatesChan(updateConfig) + userConversations := make(map[string]Conversation) - log.Printf("Started Telegram bot! Message @%s to start.", bot.Self.UserName) + // get app id + app, err := discord.Application("@me") + if err != nil { + log.Fatalf("Couldn't get app id: %v", err) + } + + _, err = discord.ApplicationCommandCreate(app.ID, os.Getenv("DISCORD_GUILD_ID"), &discordgo.ApplicationCommand{ + Name: "reload", + Description: "Start a new conversation.", + DMPermission: ref.Of(true), + }) + if err != nil { + log.Fatalf("Couldn't create reload command: %v", err) + } + + discord.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.Type != discordgo.InteractionApplicationCommand { + return + } - userConversations := make(map[int64]Conversation) + if !auth.CanInteract(i.Member.User) { + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You are not authorized to use this bot.", + }, + }) + if err != nil { + log.Printf("Couldn't send message: %v", err) + } + return + } - for update := range updates { - if update.Message == nil { - continue + if i.ApplicationCommandData().Name == "reload" { + userConversations[i.ChannelID] = Conversation{} + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Started a new conversation. Enjoy!", + }, + }) } + }) - msg := tgbotapi.NewMessage(update.Message.Chat.ID, "") - msg.ReplyToMessageID = update.Message.MessageID - msg.ParseMode = "Markdown" + discord.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { + if m.Author.ID == s.State.User.ID { + return + } - userId := strconv.FormatInt(update.Message.Chat.ID, 10) - if os.Getenv("TELEGRAM_ID") != "" && userId != os.Getenv("TELEGRAM_ID") { - msg.Text = "You are not authorized to use this bot." - bot.Send(msg) - continue + if m.GuildID != "" && ((len(m.Mentions) == 0 || m.Mentions[0].ID != s.State.User.ID) || (m.ReferencedMessage != nil && m.ReferencedMessage.Author.ID != s.State.User.ID)) { + return } - bot.Request(tgbotapi.NewChatAction(update.Message.Chat.ID, "typing")) - if !update.Message.IsCommand() { - feed, err := chatGPT.SendMessage(update.Message.Text, userConversations[update.Message.Chat.ID].ConversationID, userConversations[update.Message.Chat.ID].LastMessageID) + if !auth.CanInteract(m.Author) { + _, err := s.ChannelMessageSendReply(m.ChannelID, "You are not authorized to use this bot.", &discordgo.MessageReference{MessageID: m.ID}) if err != nil { - msg.Text = fmt.Sprintf("Error: %v", err) + log.Printf("Couldn't send message: %v", err) } + return + } - var message tgbotapi.Message - var lastResp string + query := strings.TrimSpace(strings.ReplaceAll(m.Content, fmt.Sprintf("<@%s>", s.State.User.ID), "")) - debouncedType := ratelimit.Debounce((10 * time.Second), func() { - bot.Request(tgbotapi.NewChatAction(update.Message.Chat.ID, "typing")) - }) - debouncedEdit := ratelimit.DebounceWithArgs((1 * time.Second), func(text interface{}, messageId interface{}) { - _, err = bot.Request(tgbotapi.EditMessageTextConfig{ - BaseEdit: tgbotapi.BaseEdit{ - ChatID: msg.ChatID, - MessageID: messageId.(int), - }, - Text: text.(string), - ParseMode: "Markdown", - }) - - if err != nil { - if err.Error() == "Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message" { - return - } + feed, err := chatGPT.SendMessage(query, userConversations[m.ChannelID].ConversationID, userConversations[m.ChannelID].LastMessageID) + if err != nil { + _, err = s.ChannelMessageSendReply(m.ChannelID, fmt.Sprintf("Couldn't send message: %v", err), &discordgo.MessageReference{MessageID: m.ID}) + if err != nil { + log.Printf("Couldn't send message: %v", err) + } + } - log.Printf("Couldn't edit message: %v", err) - } - }) + err = s.ChannelTyping(m.ChannelID) + if err != nil { + log.Printf("Couldn't start typing: %v", err) + } - pollResponse: - for { - debouncedType() + var msg discordgo.Message + var lastResp string - select { - case response, ok := <-feed: - if !ok { - break pollResponse - } + debouncedType := ratelimit.Debounce((10 * time.Second), func() { + err = s.ChannelTyping(m.ChannelID) + if err != nil { + log.Printf("Couldn't start typing: %v", err) + } + }) - userConversations[update.Message.Chat.ID] = Conversation{ - LastMessageID: response.MessageId, - ConversationID: response.ConversationId, - } - lastResp = markdown.EnsureFormatting(response.Message) - msg.Text = lastResp - - if message.MessageID == 0 { - message, err = bot.Send(msg) - if err != nil { - log.Fatalf("Couldn't send message: %v", err) - } - } else { - debouncedEdit(lastResp, message.MessageID) - } - } + debouncedEdit := ratelimit.DebounceWithArgs((1 * time.Second), func(text interface{}, messageId interface{}) { + _, err := s.ChannelMessageEdit(m.ChannelID, messageId.(string), text.(string)) + if err != nil { + log.Printf("Couldn't edit message: %v", err) } + }) + + pollResponse: + for { + select { + case response, ok := <-feed: + if !ok { + break pollResponse + } - _, err = bot.Request(tgbotapi.EditMessageTextConfig{ - BaseEdit: tgbotapi.BaseEdit{ - ChatID: msg.ChatID, - MessageID: message.MessageID, - }, - Text: lastResp, - ParseMode: "Markdown", - }) + userConversations[m.ChannelID] = Conversation{ + LastMessageID: response.MessageId, + ConversationID: response.ConversationId, + } - if err != nil { - if err.Error() == "Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message" { - continue + lastResp = markdown.EnsureFormatting(response.Message) + + if msg.ID == "" { + _msg, err := s.ChannelMessageSendReply(m.ChannelID, lastResp, &discordgo.MessageReference{MessageID: m.ID}) + if err != nil { + log.Printf("Couldn't send message: %v", err) + } + msg = *_msg + } else { + debouncedEdit(lastResp, msg.ID) } - log.Printf("Couldn't perform final edit on message: %v", err) + debouncedType() } - - continue } - switch update.Message.Command() { - case "help": - msg.Text = "Send a message to start talking with ChatGPT. You can use /reload at any point to clear the conversation history and start from scratch (don't worry, it won't delete the Telegram messages)." - case "start": - msg.Text = "Send a message to start talking with ChatGPT. You can use /reload at any point to clear the conversation history and start from scratch (don't worry, it won't delete the Telegram messages)." - case "reload": - userConversations[update.Message.Chat.ID] = Conversation{} - msg.Text = "Started a new conversation. Enjoy!" - default: - continue + _, err = s.ChannelMessageEdit(m.ChannelID, msg.ID, lastResp) + if err != nil { + log.Printf("Couldn't perform final edit: %v", err) } + }) - if _, err := bot.Send(msg); err != nil { - log.Printf("Couldn't send message: %v", err) - continue - } + discord.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { + log.Println("Started Discord bot.") + log.Printf("Add this bot to your server here: https://discord.com/api/oauth2/authorize?client_id=%s&permissions=2147484672&scope=bot%sapplications.commands", app.ID, "%20") + }) + + // start the bot + err = discord.Open() + if err != nil { + log.Fatalf("Couldn't start Discord bot: %v", err) } + + <-make(chan struct{}) } diff --git a/src/auth/auth.go b/src/auth/auth.go new file mode 100644 index 0000000..79716bc --- /dev/null +++ b/src/auth/auth.go @@ -0,0 +1,17 @@ +package auth + +import ( + "os" + + "github.com/bwmarrin/discordgo" +) + +func CanInteract(user *discordgo.User) bool { + allowlist := os.Getenv("DISCORD_USERNAME") + + if allowlist == "" || allowlist == (user.Username+"#"+user.Discriminator) { + return true + } + + return false +} diff --git a/src/chatgpt/chatgpt.go b/src/chatgpt/chatgpt.go index 9bb34ce..7d7fd88 100644 --- a/src/chatgpt/chatgpt.go +++ b/src/chatgpt/chatgpt.go @@ -8,9 +8,9 @@ import ( "net/http" "time" - "github.com/m1guelpf/chatgpt-telegram/src/config" - "github.com/m1guelpf/chatgpt-telegram/src/expirymap" - "github.com/m1guelpf/chatgpt-telegram/src/sse" + "github.com/m1guelpf/chatgpt-discord/src/config" + "github.com/m1guelpf/chatgpt-discord/src/expirymap" + "github.com/m1guelpf/chatgpt-discord/src/sse" ) const KEY_ACCESS_TOKEN = "accessToken" diff --git a/src/session/session.go b/src/session/session.go index 505a6c8..dce032e 100644 --- a/src/session/session.go +++ b/src/session/session.go @@ -7,7 +7,7 @@ import ( "sync" "time" - "github.com/m1guelpf/chatgpt-telegram/src/ref" + "github.com/m1guelpf/chatgpt-discord/src/ref" "github.com/playwright-community/playwright-go" )