├── cmd └── main.go ├── Makefile ├── internal ├── config │ └── config.go ├── bot │ ├── setup.go │ ├── answer.go │ ├── themes.go │ ├── service.go │ └── handler.go └── storage │ └── storage.go └── .gitignore /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | bot "bot/internal/bot" 5 | ) 6 | 7 | func main() { 8 | b := bot.New() 9 | b.Start() 10 | } 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | go run cmd/main.go 3 | 4 | race: 5 | go run -race cmd/main.go 6 | 7 | fm: 8 | gofmt -w . 9 | 10 | sc: 11 | go run scripts/script.go -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/joho/godotenv" 7 | ) 8 | 9 | type Config struct { 10 | telegramToken string 11 | redisAddr string 12 | } 13 | 14 | func New() *Config { 15 | err := godotenv.Load() 16 | if err != nil { 17 | panic(err) 18 | } 19 | return &Config{ 20 | telegramToken: os.Getenv("TGAPI"), 21 | redisAddr: os.Getenv("REDIS_ADDR"), 22 | } 23 | } 24 | 25 | func (c *Config) GetTelegramToken() string { 26 | return c.telegramToken 27 | } 28 | 29 | func (c *Config) GetRedisAddr() string { 30 | return c.redisAddr 31 | } 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore the .env file 2 | .env 3 | 4 | # Ignore the scripts directory 5 | scripts/ 6 | 7 | # Ignore any other common files and directories you may not want in your git repository 8 | # Logs 9 | *.log 10 | 11 | # Dependency directories 12 | node_modules/ 13 | vendor/ 14 | 15 | # OS generated files 16 | .DS_Store 17 | Thumbs.db 18 | 19 | # Temporary files 20 | *.tmp 21 | *.swp 22 | 23 | # Go related 24 | *.exe 25 | *.exe~ 26 | *.dll 27 | *.so 28 | *.dylib 29 | *.test 30 | *.out 31 | *.mod 32 | *.sum 33 | 34 | # Backup files 35 | *.bak 36 | *.old 37 | *.orig 38 | 39 | # Ignore Go build output 40 | /build/ 41 | 42 | # Ignore specific binary files 43 | *.o 44 | *.a 45 | *.out 46 | -------------------------------------------------------------------------------- /internal/bot/setup.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | cfg "bot/internal/config" 5 | storage "bot/internal/storage" 6 | "log" 7 | 8 | tgapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" 9 | ) 10 | 11 | type Bot struct { 12 | config *cfg.Config 13 | } 14 | 15 | func New() *Bot { 16 | return &Bot{ 17 | config: cfg.New(), 18 | } 19 | } 20 | 21 | func (b *Bot) Start() { 22 | //Стартуем бота 23 | bot, err := tgapi.NewBotAPI(b.config.GetTelegramToken()) 24 | if err != nil { 25 | panic(err) 26 | } 27 | log.Printf("Authorized on account %s", bot.Self.UserName) 28 | 29 | //Конфиги бота 30 | u := tgapi.NewUpdate(0) 31 | u.Timeout = 60 32 | updates := bot.GetUpdatesChan(u) 33 | 34 | //Создание хэндлера сообщений 35 | store := storage.New() 36 | updateHandle := NewHandler(bot, store) 37 | 38 | //Основной цикл работы бота 39 | for update := range updates { 40 | go updateHandle.Handle(update) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/bot/answer.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import "fmt" 4 | 5 | type Answers struct { 6 | StartFirst, 7 | StartSearch, 8 | Next, 9 | Exit, 10 | ExitPartner, 11 | Help, 12 | AllReadyChatting, 13 | AllReadyWaiting, 14 | BotNotRunned, 15 | ZeroUser, 16 | Error, 17 | OfflinePartner string 18 | StartChat func(theme string) string 19 | } 20 | 21 | func GetAnswer() Answers { 22 | return Answers{ 23 | StartFirst: "👋 Привет! Я бот, который помогает найти собеседника. Напиши /run, чтобы начать!", 24 | StartSearch: "🔍 Ищу для тебя собеседника, подожди немного...", 25 | StartChat: func(theme string) string { 26 | return fmt.Sprintf("🎉 Ура! Нашел тебе собеседника, начнем общение!\nТема разговора:%s", theme) 27 | }, 28 | Next: "🔄 Беседа окончена. Ищу для тебя нового собеседника...", 29 | Exit: "👋 Ты вышел из чата. Если захочешь вернуться, всегда рад!", 30 | ExitPartner: "🚪 Собеседник ушел. Ищу для тебя нового друга...", 31 | Help: "ℹ️ Чтобы начать общение, напиши /run и следуй инструкциям!", 32 | AllReadyChatting: "💬 Ты уже в чате! Если хочешь нового собеседника, напиши /next.", 33 | AllReadyWaiting: "⏳ Ты уже в очереди на поиск. Жди немного!", 34 | BotNotRunned: "🚀 Ты еще не начал общение. Напиши /run, чтобы начать!", 35 | ZeroUser: "🕐 Пока собеседников нет, но ты в очереди. Немного подожди!", 36 | OfflinePartner: "😴 Собеседник не в сети.", 37 | Error: "🤕 Произошла ошибка. Попробуй еще раз!", 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /internal/bot/themes.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | type Theme struct { 4 | ThemeText string 5 | Tags []string 6 | } 7 | 8 | // функция для получение тем 9 | func GetThemes() []Theme { 10 | //TODO: Сделать темы через бд 11 | 12 | return []Theme{ 13 | { 14 | ThemeText: "Какое ваше любимое хобби?", 15 | Tags: []string{ 16 | "личное", 17 | "развлечение", 18 | "хобби", 19 | }, 20 | }, 21 | { 22 | ThemeText: "Какие книги вы читали в последнее время?", 23 | Tags: []string{ 24 | "личное", 25 | "чтение", 26 | "образование", 27 | }, 28 | }, 29 | { 30 | ThemeText: "Какой ваш любимый фильм и почему?", 31 | Tags: []string{ 32 | "личное", 33 | "развлечение", 34 | "кино", 35 | }, 36 | }, 37 | { 38 | ThemeText: "Какие планы на выходные?", 39 | Tags: []string{ 40 | "личное", 41 | "планы", 42 | "выходные", 43 | }, 44 | }, 45 | { 46 | ThemeText: "Какую музыку вы любите слушать?", 47 | Tags: []string{ 48 | "личное", 49 | "музыка", 50 | "развлечение", 51 | }, 52 | }, 53 | { 54 | ThemeText: "Какие у вас любимые спортивные команды?", 55 | Tags: []string{ 56 | "спорт", 57 | "личное", 58 | "развлечение", 59 | }, 60 | }, 61 | { 62 | ThemeText: "Какие места вы хотели бы посетить?", 63 | Tags: []string{ 64 | "путешествия", 65 | "личное", 66 | "желания", 67 | }, 68 | }, 69 | { 70 | ThemeText: "Какой ваш любимый вид спорта?", 71 | Tags: []string{ 72 | "спорт", 73 | "личное", 74 | "развлечение", 75 | }, 76 | }, 77 | { 78 | ThemeText: "Какие у вас цели на этот год?", 79 | Tags: []string{ 80 | "личное", 81 | "цели", 82 | "планы", 83 | }, 84 | }, 85 | { 86 | ThemeText: "Что вас вдохновляет?", 87 | Tags: []string{ 88 | "личное", 89 | "вдохновение", 90 | "мотивация", 91 | }, 92 | }, 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /internal/bot/service.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "errors" 5 | "math/rand" 6 | 7 | tgapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" 8 | ) 9 | 10 | func (m *UpdateHandle) Send(ID int64, text string) { 11 | msg := tgapi.NewMessage(ID, text) 12 | m.Bot.Send(msg) 13 | } 14 | 15 | type Keyboard struct { 16 | Type string // "inline" or "menu" 17 | Buttons [][]string 18 | } 19 | 20 | func (m *UpdateHandle) SendWithKeyboard(ID int64, text string, keys Keyboard) { 21 | msg := tgapi.NewMessage(ID, text) 22 | switch keys.Type { 23 | case "inline": 24 | 25 | case "menu": 26 | Buttons := make([][]tgapi.KeyboardButton, 0, len(keys.Buttons)) 27 | for _, row := range keys.Buttons { 28 | Row := make([]tgapi.KeyboardButton, 0, len(row)) 29 | for _, btn := range row { 30 | Row = append(Row, tgapi.NewKeyboardButton(btn)) 31 | } 32 | Buttons = append(Buttons, tgapi.NewKeyboardButtonRow(Row...)) 33 | } 34 | msg.ReplyMarkup = tgapi.NewReplyKeyboard(Buttons...) 35 | } 36 | m.Bot.Send(msg) 37 | } 38 | 39 | func (m *UpdateHandle) SendWithCleanKeyboard(ID int64, text string) { 40 | msg := tgapi.NewMessage(ID, text) 41 | msg.ReplyMarkup = tgapi.NewRemoveKeyboard(true) 42 | m.Bot.Send(msg) 43 | } 44 | 45 | func (m *UpdateHandle) StartChat(ID int64) (int64, error) { 46 | //получение пользователя 47 | user, err := m.Storage.GetUser(ID) 48 | if err != nil { 49 | return 0, err 50 | } 51 | 52 | //Если пользователя уже в чате 53 | if user.Action == "chat" { 54 | return 0, errors.New("User is already in chat") 55 | } 56 | 57 | //Если пользователь в списке ожидания 58 | if user.Action == "waiting" { 59 | partner, err := m.Storage.GetPartner(user) 60 | if err != nil { 61 | return 0, err 62 | } 63 | //Если есть собеседник 64 | m.Storage.CreateChat(ID, partner.ID) 65 | 66 | return partner.ID, nil 67 | } 68 | 69 | return 0, errors.New("User is offline") 70 | } 71 | 72 | func (m *UpdateHandle) CloseChat(ID int64) (int64, error) { 73 | //получение пользователя 74 | user, err := m.Storage.GetUser(ID) 75 | if err != nil { 76 | return 0, err 77 | } 78 | 79 | //Удалить партнеров 80 | m.Storage.CleanPartner(ID) 81 | m.Storage.CleanPartner(user.PartnerID) 82 | 83 | return user.PartnerID, nil 84 | } 85 | 86 | func (m *UpdateHandle) GetRandomTheme() string { 87 | return m.Themes[rand.Intn(len(m.Themes))].ThemeText 88 | } 89 | -------------------------------------------------------------------------------- /internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | cfg "bot/internal/config" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | 10 | "github.com/go-redis/redis/v8" 11 | ) 12 | 13 | type User struct { 14 | ID int64 15 | Name string 16 | PartnerID int64 17 | PartnerName string 18 | Action string //chat, waiting, offline, 19 | } 20 | 21 | 22 | type Storage struct { 23 | config *cfg.Config 24 | client *redis.Client 25 | } 26 | 27 | var ctx = context.Background() 28 | 29 | func New() *Storage { 30 | config := cfg.New() 31 | opt, _ := redis.ParseURL(config.GetRedisAddr()) 32 | client := redis.NewClient(opt) 33 | sts := client.Ping(ctx) 34 | if sts.Err() != nil { 35 | panic(sts.Err()) 36 | } 37 | return &Storage{ 38 | config: config, 39 | client: client, 40 | } 41 | } 42 | 43 | func (s *Storage) GetUser(UserID int64) (User, error) { 44 | return s.getUserFromRedis(UserID) 45 | } 46 | 47 | func (s *Storage) IsPartnerOnline(PartnerID int64) bool { 48 | partner, err := s.getUserFromRedis(PartnerID) 49 | if err != nil { 50 | return false 51 | } 52 | return partner.Action == "chat" 53 | } 54 | 55 | func (s *Storage) SetUser(UserID int64, UserName string) error { 56 | user := User{ 57 | ID: UserID, 58 | Name: UserName, 59 | PartnerID: 0, 60 | PartnerName: "", 61 | Action: "waiting", 62 | } 63 | 64 | return s.setUserToRedis(user) 65 | } 66 | 67 | func (s *Storage) CreateChat(UserID int64, PartnerID int64) error { 68 | user, err := s.getUserFromRedis(UserID) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | partner, err := s.getUserFromRedis(PartnerID) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | user.PartnerID = PartnerID 79 | user.PartnerName = partner.Name 80 | user.Action = "chat" 81 | partner.PartnerID = UserID 82 | partner.PartnerName = user.Name 83 | partner.Action = "chat" 84 | 85 | err = s.setUserToRedis(user) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | err = s.setUserToRedis(partner) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | return nil 96 | } 97 | 98 | func (s *Storage) GetPartner(user User) (User, error) { 99 | keys, err := s.client.Keys(ctx, "user:*").Result() 100 | if err != nil { 101 | return User{}, err 102 | } 103 | 104 | for _, key := range keys { 105 | val, err := s.client.Get(ctx, key).Result() 106 | if err != nil { 107 | continue 108 | } 109 | 110 | partner, err := s.unmarshalUser(val) 111 | if err != nil { 112 | continue 113 | } 114 | 115 | if partner.Action == "waiting" && partner.ID != user.ID { 116 | return partner, nil 117 | } 118 | } 119 | 120 | return User{}, errors.New("No partner found") 121 | } 122 | 123 | func (s *Storage) CleanPartner(UserID int64) error { 124 | user, err := s.getUserFromRedis(UserID) 125 | if err != nil { 126 | return errors.New("User does not exist") 127 | } 128 | 129 | user.PartnerID = 0 130 | user.Action = "waiting" 131 | 132 | return s.setUserToRedis(user) 133 | } 134 | 135 | func (s *Storage) DeleteUser(UserID int64) error { 136 | err := s.client.Del(ctx, s.getUserKey(UserID)).Err() 137 | if err != nil { 138 | return err 139 | } 140 | 141 | return nil 142 | } 143 | 144 | func (s *Storage) getUserKey(UserID int64) string { 145 | return "user:" + fmt.Sprint(UserID) 146 | } 147 | 148 | func (s *Storage) marshalUser(user User) (string, error) { 149 | userJSON, err := json.Marshal(user) 150 | if err != nil { 151 | return "", err 152 | } 153 | return string(userJSON), nil 154 | } 155 | 156 | func (s *Storage) unmarshalUser(data string) (User, error) { 157 | var user User 158 | err := json.Unmarshal([]byte(data), &user) 159 | if err != nil { 160 | return User{}, err 161 | } 162 | return user, nil 163 | } 164 | 165 | func (s *Storage) getUserFromRedis(UserID int64) (User, error) { 166 | val, err := s.client.Get(ctx, s.getUserKey(UserID)).Result() 167 | if err == redis.Nil { 168 | return User{}, errors.New("User does not exist") 169 | } else if err != nil { 170 | return User{}, err 171 | } 172 | 173 | return s.unmarshalUser(val) 174 | } 175 | 176 | func (s *Storage) setUserToRedis(user User) error { 177 | userJSON, err := s.marshalUser(user) 178 | if err != nil { 179 | return err 180 | } 181 | 182 | err = s.client.Set(ctx, s.getUserKey(user.ID), userJSON, 0).Err() 183 | if err != nil { 184 | return err 185 | } 186 | 187 | return nil 188 | } 189 | -------------------------------------------------------------------------------- /internal/bot/handler.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | storage "bot/internal/storage" 5 | "fmt" 6 | "log" 7 | 8 | tgapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" 9 | ) 10 | 11 | 12 | 13 | type UpdateHandle struct { 14 | Bot *tgapi.BotAPI 15 | Storage *storage.Storage 16 | Ans Answers 17 | Themes []Theme 18 | } 19 | 20 | func NewHandler(bot *tgapi.BotAPI, Storage *storage.Storage) *UpdateHandle { 21 | return &UpdateHandle{ 22 | Bot: bot, 23 | Storage: Storage, 24 | Ans: GetAnswer(), 25 | Themes: GetThemes(), 26 | } 27 | } 28 | 29 | //Сообщений можно отправлять только в handle 30 | 31 | func (m *UpdateHandle) Handle(upd tgapi.Update) { 32 | if upd.Message == nil { 33 | return 34 | } 35 | if upd.Message.IsCommand() { 36 | // Обработка команд 37 | switch upd.Message.Command() { 38 | case "run": 39 | m.CommandRun(upd.Message.Chat.ID, upd.Message.From.UserName) 40 | case "start": 41 | m.CommandStart(upd.Message) 42 | case "next": 43 | m.CommandNext(upd.Message) 44 | case "exit": 45 | m.CommandExit(upd.Message) 46 | } 47 | } else { 48 | //Обработка сообщений 49 | user, err := m.Storage.GetUser(upd.Message.Chat.ID) 50 | if err != nil { 51 | //Если пользователя нет в списке 52 | m.Send(upd.Message.Chat.ID, m.Ans.BotNotRunned) 53 | } else { 54 | //Если пользователя есть в списке 55 | switch user.Action { 56 | case "waiting": 57 | m.Send(upd.Message.Chat.ID, m.Ans.AllReadyWaiting) 58 | case "chat": 59 | m.SendText(user, upd.Message) 60 | case "offline": 61 | m.Send(upd.Message.Chat.ID, m.Ans.BotNotRunned) 62 | } 63 | } 64 | 65 | } 66 | } 67 | 68 | func (m *UpdateHandle) SendText(user storage.User, message *tgapi.Message) { 69 | ok := m.Storage.IsPartnerOnline(user.PartnerID) 70 | if !ok { 71 | m.Send(user.ID, m.Ans.OfflinePartner) 72 | m.Storage.CleanPartner(user.ID) 73 | m.StartChat(user.ID) 74 | return 75 | } 76 | text := fmt.Sprintf("💬%s: %s", user.PartnerName, message.Text) 77 | m.Send(user.PartnerID, text) 78 | } 79 | 80 | func (m *UpdateHandle) CommandExit(message *tgapi.Message) { 81 | //Получение пользователя 82 | user, err := m.Storage.GetUser(message.Chat.ID) 83 | if err != nil { 84 | //Если пользователя нет в списке 85 | m.Send(message.Chat.ID, m.Ans.BotNotRunned) 86 | return 87 | } 88 | //Удаление пользователя из списка 89 | m.Send(message.Chat.ID, m.Ans.Exit) 90 | m.Storage.DeleteUser(message.Chat.ID) 91 | 92 | //Поиск нового чата для собеседника 93 | m.Send(user.PartnerID, m.Ans.ExitPartner) 94 | m.Storage.CleanPartner(user.PartnerID) 95 | m.StartChat(user.PartnerID) 96 | } 97 | 98 | func (m *UpdateHandle) CommandNext(message *tgapi.Message) { 99 | //Получение пользователя 100 | user, err := m.Storage.GetUser(message.Chat.ID) 101 | if err != nil { 102 | //Если пользователя нет в списке 103 | m.Send(user.ID, m.Ans.BotNotRunned) 104 | } else { 105 | //Если пользователя есть в списке 106 | if user.Action == "waiting" { 107 | m.Send(user.ID, m.Ans.AllReadyWaiting) 108 | } else if user.Action == "chat" { 109 | m.Send(user.ID, m.Ans.Next) 110 | m.Send(user.ID, m.Ans.ExitPartner) 111 | m.CommandRun(user.ID, user.Name) 112 | m.CommandRun(user.PartnerID, user.PartnerName) 113 | } 114 | } 115 | 116 | } 117 | 118 | func (m *UpdateHandle) CommandStart(message *tgapi.Message) { 119 | //Отправка сообщения о начале работы 120 | keyboard := Keyboard{ 121 | Type: "menu", 122 | Buttons: [][]string{{"/run"}}, 123 | } 124 | m.SendWithKeyboard(message.Chat.ID, m.Ans.StartFirst, keyboard) 125 | } 126 | 127 | func (m *UpdateHandle) CommandRun(ID int64, UserName string) { 128 | //Добавление пользователя в список 129 | err := m.Storage.SetUser(ID, UserName) 130 | if err != nil { 131 | //Если пользователя уже в списке 132 | log.Println(err) 133 | m.Send(ID, m.Ans.AllReadyChatting) 134 | return 135 | } 136 | 137 | //Отправка сообщения о начале поиска 138 | m.SendWithCleanKeyboard(ID, m.Ans.StartSearch) 139 | 140 | //Начало чата 141 | partnerID, err := m.StartChat(ID) 142 | if err != nil { 143 | switch err.Error() { 144 | case "User is already in chat": 145 | m.Send(ID, m.Ans.AllReadyChatting) 146 | case "User is offline": 147 | m.Send(ID, m.Ans.BotNotRunned) 148 | case "No partner found": 149 | m.Send(ID, m.Ans.ZeroUser) 150 | case "User does not exist": 151 | m.Send(ID, m.Ans.BotNotRunned) 152 | default: 153 | m.Send(ID, m.Ans.Error) 154 | } 155 | return 156 | } 157 | theme := m.GetRandomTheme() 158 | m.Send(ID, m.Ans.StartChat(theme)) 159 | m.Send(partnerID, m.Ans.StartChat(theme)) 160 | } 161 | --------------------------------------------------------------------------------