From a284e3ef2945a1dd0f2cb9ded268ac308f67d999 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Thu, 14 Aug 2025 09:56:31 +0300 Subject: [PATCH] Add basic telegram bot --- .env.example | 4 + go.mod | 1 + go.sum | 2 + internal/controller/tg/tg.go | 328 +++++++++++++++++++++++++++++++++++ main.go | 33 +++- 5 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 internal/controller/tg/tg.go diff --git a/.env.example b/.env.example index 005a399..0a4476a 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,7 @@ S3_ENDPOINT= YANDEX_CLOUD_API_KEY=your_api_key_here # ID папки в Yandex Cloud (получить в консоли Yandex Cloud) YANDEX_CLOUD_FOLDER_ID=your_folder_id_here + +# Telegram Bot Configuration +# Токен Telegram бота (получить у @BotFather в Telegram) +TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here diff --git a/go.mod b/go.mod index 544e5f6..695c4d6 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0 github.com/doug-martin/goqu/v9 v9.19.0 github.com/gin-gonic/gin v1.10.1 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.17 diff --git a/go.sum b/go.sum index 347498c..7038f84 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,8 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91 github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +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/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= diff --git a/internal/controller/tg/tg.go b/internal/controller/tg/tg.go new file mode 100644 index 0000000..a7f613d --- /dev/null +++ b/internal/controller/tg/tg.go @@ -0,0 +1,328 @@ +package tg + +import ( + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "git.vakhrushev.me/av/transcriber/internal/contract" + "git.vakhrushev.me/av/transcriber/internal/service" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +type TelegramController struct { + bot *tgbotapi.BotAPI + transcribeService *service.TranscribeService + jobRepo contract.TranscriptJobRepository + logger *slog.Logger +} + +func NewTelegramController(transcribeService *service.TranscribeService, jobRepo contract.TranscriptJobRepository, logger *slog.Logger) (*TelegramController, error) { + botToken := os.Getenv("TELEGRAM_BOT_TOKEN") + if botToken == "" { + return nil, &EmptyBotTokenError{} + } + + bot, err := tgbotapi.NewBotAPI(botToken) + if err != nil { + return nil, err + } + + controller := &TelegramController{ + bot: bot, + transcribeService: transcribeService, + jobRepo: jobRepo, + logger: logger, + } + + return controller, nil +} + +func (c *TelegramController) Start() { + c.logger.Info("Telegram bot started", "username", c.bot.Self.UserName) + + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + + updates := c.bot.GetUpdatesChan(u) + + for update := range updates { + if update.Message == nil { // ignore any non-Message updates + continue + } + + // Handle commands + if update.Message.IsCommand() { + // Extract the command from the Message + switch update.Message.Command() { + case "start": + c.handleStartCommand(update.Message) + case "help": + c.handleHelpCommand(update.Message) + } + continue + } + + // Handle audio messages and files + if update.Message.Audio != nil { + c.handleAudioMessage(update.Message) + } else if update.Message.Voice != nil { + c.handleVoiceMessage(update.Message) + } else if update.Message.Document != nil { + c.handleDocumentMessage(update.Message) + } + } +} + +func (c *TelegramController) Stop() { + c.logger.Info("Telegram bot stopped") +} + +func (c *TelegramController) handleStartCommand(message *tgbotapi.Message) { + msg := tgbotapi.NewMessage(message.Chat.ID, "Привет! Я бот для расшифровки аудиосообщений. Отправь мне голосовое сообщение или аудиофайл, и я пришлю тебе текст.") + msg.ReplyToMessageID = message.MessageID + + c.bot.Send(msg) +} + +func (c *TelegramController) handleHelpCommand(message *tgbotapi.Message) { + helpText := `Я бот для расшифровки аудиосообщений и аудиофайлов. + +Просто отправь мне: +- Голосовое сообщение +- Аудиофайл (mp3, wav, ogg и др.) + +Я пришлю тебе текст расшифровки. + +Команды: +/start - Начало работы с ботом +/help - Показать эту справку` + + msg := tgbotapi.NewMessage(message.Chat.ID, helpText) + msg.ReplyToMessageID = message.MessageID + + c.bot.Send(msg) +} + +func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) { + // Отправляем сообщение о начале обработки + progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...") + progressMsg.ReplyToMessageID = message.MessageID + sentProgressMsg, err := c.bot.Send(progressMsg) + if err != nil { + c.logger.Error("Failed to send progress message", "error", err) + return + } + + // Скачиваем файл + fileReader, fileName, err := c.downloadAudioFile(message.Audio.FileID) + if err != nil { + c.logger.Error("Failed to download audio file", "error", err) + errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.") + c.bot.Send(errorMsg) + return + } + defer fileReader.Close() + + // Обрабатываем файл + job, err := c.transcribeService.CreateTranscribeJob(fileReader, fileName) + if err != nil { + c.logger.Error("Failed to create transcribe job", "error", err) + errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.") + c.bot.Send(errorMsg) + return + } + + // Отправляем сообщение об успешном создании задачи + successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", job.Id)) + successMsg.ReplyToMessageID = message.MessageID + c.bot.Send(successMsg) + + // Отправляем результат расшифровки (асинхронно) + go c.sendTranscriptionResult(message.Chat.ID, job.Id, sentProgressMsg.MessageID) +} + +func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) { + // Отправляем сообщение о начале обработки + progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю голосовое сообщение...") + progressMsg.ReplyToMessageID = message.MessageID + sentProgressMsg, err := c.bot.Send(progressMsg) + if err != nil { + c.logger.Error("Failed to send progress message", "error", err) + return + } + + // Скачиваем файл + fileReader, fileName, err := c.downloadAudioFile(message.Voice.FileID) + if err != nil { + c.logger.Error("Failed to download voice file", "error", err) + errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании голосового сообщения. Попробуйте еще раз.") + c.bot.Send(errorMsg) + return + } + defer fileReader.Close() + + // Обрабатываем файл + job, err := c.transcribeService.CreateTranscribeJob(fileReader, fileName) + if err != nil { + c.logger.Error("Failed to create transcribe job", "error", err) + errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.") + c.bot.Send(errorMsg) + return + } + + // Отправляем сообщение об успешном создании задачи + successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", job.Id)) + successMsg.ReplyToMessageID = message.MessageID + c.bot.Send(successMsg) + + // Отправляем результат расшифровки (асинхронно) + go c.sendTranscriptionResult(message.Chat.ID, job.Id, sentProgressMsg.MessageID) +} + +func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) { + // Проверяем, является ли документ аудиофайлом + if !c.isAudioDocument(message.Document) { + return + } + + // Отправляем сообщение о начале обработки + progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...") + progressMsg.ReplyToMessageID = message.MessageID + sentProgressMsg, err := c.bot.Send(progressMsg) + if err != nil { + c.logger.Error("Failed to send progress message", "error", err) + return + } + + // Скачиваем файл + fileReader, fileName, err := c.downloadAudioFile(message.Document.FileID) + if err != nil { + c.logger.Error("Failed to download document file", "error", err) + errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.") + c.bot.Send(errorMsg) + return + } + defer fileReader.Close() + + // Обрабатываем файл + job, err := c.transcribeService.CreateTranscribeJob(fileReader, fileName) + if err != nil { + c.logger.Error("Failed to create transcribe job", "error", err) + errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.") + c.bot.Send(errorMsg) + return + } + + // Отправляем сообщение об успешном создании задачи + successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", job.Id)) + successMsg.ReplyToMessageID = message.MessageID + c.bot.Send(successMsg) + + // Отправляем результат расшифровки (асинхронно) + go c.sendTranscriptionResult(message.Chat.ID, job.Id, sentProgressMsg.MessageID) +} + +func (c *TelegramController) downloadAudioFile(fileID string) (io.ReadCloser, string, error) { + // Получаем информацию о файле + file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID}) + if err != nil { + return nil, "", fmt.Errorf("failed to get file info: %w", err) + } + + // Скачиваем файл + fileURL := file.Link(c.bot.Token) + resp, err := http.Get(fileURL) + if err != nil { + return nil, "", fmt.Errorf("failed to download file: %w", err) + } + + // Получаем имя файла из URL + fileName := file.FilePath + if fileName == "" { + fileName = "audio.ogg" + } + + return resp.Body, fileName, nil +} + +func (c *TelegramController) sendTranscriptionResult(chatID int64, jobID string, progressMessageID int) { + // Периодически проверяем статус задачи + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + timeout := time.After(10 * time.Minute) // Максимальное время ожидания 10 минут + + for { + select { + case <-ticker.C: + // Проверяем статус задачи + job, err := c.jobRepo.GetByID(jobID) + if err != nil { + c.logger.Error("Failed to get job", "job_id", jobID, "error", err) + continue + } + + switch job.State { + case "done": + // Отправляем результат + if job.TranscriptionText != nil { + resultMsg := tgbotapi.NewMessage(chatID, *job.TranscriptionText) + resultMsg.ReplyToMessageID = progressMessageID + c.bot.Send(resultMsg) + } else { + resultMsg := tgbotapi.NewMessage(chatID, "Расшифровка завершена, но текст пуст.") + resultMsg.ReplyToMessageID = progressMessageID + c.bot.Send(resultMsg) + } + return + case "failed": + // Отправляем сообщение об ошибке + var errorMsg string + if job.ErrorText != nil { + errorMsg = fmt.Sprintf("Ошибка при расшифровке: %s", *job.ErrorText) + } else { + errorMsg = "Ошибка при расшифровке аудиофайла." + } + resultMsg := tgbotapi.NewMessage(chatID, errorMsg) + resultMsg.ReplyToMessageID = progressMessageID + c.bot.Send(resultMsg) + return + } + case <-timeout: + // Время ожидания истекло + resultMsg := tgbotapi.NewMessage(chatID, "Время ожидания результата расшифровки истекло. Попробуйте позже проверить статус задачи.") + resultMsg.ReplyToMessageID = progressMessageID + c.bot.Send(resultMsg) + return + } + } +} + +func (c *TelegramController) isAudioDocument(document *tgbotapi.Document) bool { + // Проверяем MIME-тип документа + if document.MimeType != "" { + return strings.HasPrefix(document.MimeType, "audio/") || strings.HasPrefix(document.MimeType, "video/") + } + + // Проверяем расширение файла + audioExtensions := []string{".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac", ".wma"} + filename := document.FileName + for _, ext := range audioExtensions { + if len(filename) >= len(ext) && strings.ToLower(filename[len(filename)-len(ext):]) == ext { + return true + } + } + + return false +} + +type EmptyBotTokenError struct{} + +func (e *EmptyBotTokenError) Error() string { + return "telegram bot token is empty" +} diff --git a/main.go b/main.go index 65f4c2a..a8e4326 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite" httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" + tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg" "git.vakhrushev.me/av/transcriber/internal/controller/worker" "git.vakhrushev.me/av/transcriber/internal/service" "github.com/doug-martin/goqu/v9" @@ -93,6 +94,31 @@ func main() { // Создаем сервисы transcribeService := service.NewTranscribeService(jobRepo, fileRepo, metaviewer, converter, recognizer, logger) + // Создаем контекст для graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Создаем WaitGroup для ожидания завершения всех воркеров + var wg sync.WaitGroup + + // Создаем Telegram бот + tgController, err := tgcontroller.NewTelegramController(transcribeService, jobRepo, logger) + if err != nil { + logger.Error("Failed to create Telegram controller", "error", err) + // Не останавливаем приложение, если Telegram бот не создан + } else { + // Запускаем Telegram бот в отдельной горутине + wg.Add(1) + go func() { + defer wg.Done() + logger.Info("Starting Telegram bot") + tgController.Start() + }() + + // Добавляем функцию остановки бота в контекст завершения + defer tgController.Stop() + } + // Создаем воркеры conversionWorker := worker.NewCallbackWorker("conversion_worker", transcribeService.FindAndRunConversionJob, logger) transcribeWorker := worker.NewCallbackWorker("transcribe_worker", transcribeService.FindAndRunTranscribeJob, logger) @@ -104,13 +130,6 @@ func main() { checkWorker, } - // Создаем контекст для graceful shutdown - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Создаем WaitGroup для ожидания завершения всех воркеров - var wg sync.WaitGroup - // Запускаем воркеры в отдельных горутинах for _, w := range workers { wg.Add(1)