// Package tg provides Telegram bot functionality for RSMon. package tg import ( "errors" "fmt" "log" "net/http" "os" "strconv" "strings" "time" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "rocketgit.ru/rsmon/worker/app/models" ) var bot *tgbotapi.BotAPI type silentTelegramLogger struct{} func (silentTelegramLogger) Println(...interface{}) {} func (silentTelegramLogger) Printf(string, ...interface{}) {} func init() { _ = tgbotapi.SetLogger(silentTelegramLogger{}) } const ( defaultBaseURL = "https://rsmon.ru" telegramAPITimeout = 15 * time.Second ) func telegramAPIEndpoint(rawURL string) string { if rawURL == "" { return tgbotapi.APIEndpoint } return strings.TrimRight(rawURL, "/") + "/bot%s/%s" } func newBotAPI(token, apiURL string) (*tgbotapi.BotAPI, error) { return tgbotapi.NewBotAPIWithClient(token, telegramAPIEndpoint(apiURL), &http.Client{Timeout: telegramAPITimeout}) } func botAPIForCredential(cred *models.NotificationCredential) (*tgbotapi.BotAPI, error) { if cred == nil { return nil, errors.New("telegram credential is nil") } token, err := cred.GetSecret() if err != nil { return nil, fmt.Errorf("telegram credential secret: %w", err) } apiURL := "" if cred.APIURL != nil { apiURL = *cred.APIURL } client, err := newBotAPI(token, apiURL) if err != nil { return nil, err } client.Debug = false return client, nil } // GetContact finds or creates a contact for the given Telegram chat. func GetContact(kind, name string, chatID int64) (models.Contact, error) { contact := models.Contact{} if kind == "supergroup" { kind = "group" } ckind := "telegram_" + kind cvalue := strconv.FormatInt(chatID, 10) models.DB().Where("kind = ? AND value = ?", ckind, cvalue).Find(&contact) contact.Kind = ckind contact.Value = cvalue contact.Name = name if contact.Token == "" { contact.SetToken() } err := models.DB().Save(&contact).Error return contact, err } // Init initializes the Telegram bot API client. func Init() error { if bot == nil { client, err := defaultBotAPI() if err != nil { return err } bot = client } return nil } func defaultBotAPI() (*tgbotapi.BotAPI, error) { creds, err := models.EnabledCredentialsByKind(models.CredentialKindTelegram) if err != nil { return nil, err } if len(creds) == 0 { return nil, errors.New("telegram bot not configured") } return botAPIForCredential(&creds[0]) } func botAPIForCredentialID(id int64) (*tgbotapi.BotAPI, error) { if id <= 0 { return defaultBotAPI() } cred, err := models.FindCredential(id) if err != nil { return nil, err } if cred.Kind != models.CredentialKindTelegram { return nil, fmt.Errorf("credential %d is %q, not telegram", id, cred.Kind) } if cred.Enabled != nil && !*cred.Enabled { return nil, fmt.Errorf("credential %d is disabled", id) } return botAPIForCredential(cred) } // SendMessage sends a Telegram message to the given chat ID string. func SendMessage(chatIDStr, message string) error { var err error iChatID, err := strconv.ParseInt(chatIDStr, 10, 64) if err != nil { return err } err = Init() if err != nil { return err } msg := tgbotapi.NewMessage(iChatID, "") msg.Text = message _, err = bot.Send(msg) recordSentMessage(iChatID, message, err) if err != nil { log.Println(err) } return err } // SendMessageWithToken is the credential-scoped variant used by the worker // executor. It builds a one-shot bot client from the credential's BotToken + // optional APIURL, then sends the message. Returns the bot's response error // so callers can translate into retryable/permanent status. func SendMessageWithToken(chatIDStr, message string, cred *models.NotificationCredential) error { if cred == nil { return errors.New("telegram credential is nil") } if cred.Kind != models.CredentialKindTelegram { return fmt.Errorf("credential %d is not telegram (kind=%s)", cred.ID, cred.Kind) } chatID, err := strconv.ParseInt(chatIDStr, 10, 64) if err != nil { return err } client, err := botAPIForCredential(cred) if err != nil { return err } msg := tgbotapi.NewMessage(chatID, message) _, err = client.Send(msg) recordSentMessage(chatID, message, err) return err } // Start starts the Telegram bot update loop. func Start() { StartWithCredentialID(0) } // StartWithCredentialID starts the Telegram bot update loop for a specific credential. func StartWithCredentialID(credentialID int64) { var err error if credentialID > 0 { bot, err = botAPIForCredentialID(credentialID) } else { err = Init() } if err != nil { log.Println(err) return } log.Printf("Authorized on account %s", bot.Self.UserName) SetBotCommands(bot) if _, err := bot.Request(tgbotapi.DeleteWebhookConfig{DropPendingUpdates: false}); err != nil { log.Println("telegram delete webhook:", err) return } u := tgbotapi.NewUpdate(0) u.Timeout = 10 updates := bot.GetUpdatesChan(u) markBotOnline("") go heartbeat() for update := range updates { ProcessUpdate(bot, update) } } // ProcessWebhookUpdate handles one Telegram webhook update for a credential. func ProcessWebhookUpdate(cred *models.NotificationCredential, update tgbotapi.Update) error { client, err := botAPIForCredential(cred) if err != nil { return err } ProcessUpdate(client, update) return nil } // SetCredentialCommands registers the slash command menu for a credential-backed bot. func SetCredentialCommands(cred *models.NotificationCredential) error { client, err := botAPIForCredential(cred) if err != nil { return err } SetBotCommands(client) return nil } // ProcessUpdate handles one Telegram update from polling or webhook delivery. func ProcessUpdate(client *tgbotapi.BotAPI, update tgbotapi.Update) { if update.Message == nil { // ignore any non-Message updates for now return } recordReceivedMessage(update.Message, nil) markBotOnline("") if !update.Message.IsCommand() { return } msg := tgbotapi.NewMessage(update.Message.Chat.ID, "") contact, err := contactForMessage(update.Message) if err != nil { msg.Text = "Внутренняя ошибка rsmon: " + err.Error() sendAndRecord(client, msg) return } msg.Text = commandResponse(update.Message, contact) sendAndRecord(client, msg) } func contactForMessage(message *tgbotapi.Message) (models.Contact, error) { var name string switch message.Chat.Type { case "private": name = strings.TrimSpace("@" + strings.TrimSpace(message.Chat.UserName+" "+message.Chat.FirstName+" "+message.Chat.LastName)) case "group", "supergroup": name = message.Chat.Title default: return models.Contact{}, fmt.Errorf("unknown chat type: %s", message.Chat.Type) } return GetContact(message.Chat.Type, name, message.Chat.ID) } func commandResponse(message *tgbotapi.Message, contact models.Contact) string { baseURL := strings.TrimRight(os.Getenv("BASE_URL"), "/") if baseURL == "" { baseURL = defaultBaseURL } link := baseURL + "/telegram?token=" + contact.Token switch message.Command() { case "start": return "Для завершения добавления Telegram-уведомлений перейдите по ссылке:\n" + link + "\n\n/id - показать ID чата\n/info - статус привязки\n/settings - настройки\n/stop - отключить уведомления" case "id": return fmt.Sprintf("chat_id: %d\ntype: %s", message.Chat.ID, message.Chat.Type) case "info": return contactInfo(contact, link) case "settings": return "Настройки Telegram-контакта доступны в RSMon:\n" + link + "\n\n/stop - отключить уведомления для этого чата" case "stop": disabled, disableErr := disableChatNotifications(message.Chat.ID) if disableErr != nil { return "Не удалось отключить уведомления: " + disableErr.Error() } return fmt.Sprintf("Telegram-уведомления для этого чата отключены: %d", disabled) case "help": return helpText() default: return "Неизвестная команда. " + helpText() } } func contactInfo(contact models.Contact, link string) string { bound := contact.UserID != nil || contact.AccountID != nil status := "не привязан" if bound { status = "привязан" } return fmt.Sprintf("Контакт: %s\nТип: %s\nID: %s\nСтатус: %s\nСсылка настройки: %s", contact.Name, contact.Kind, contact.Value, status, link) } func helpText() string { return "/start - подключить Telegram-уведомления\n" + "/id - показать ID чата\n" + "/info - информация о контакте\n" + "/settings - ссылка на настройки\n" + "/stop - отключить уведомления" } func sendAndRecord(client *tgbotapi.BotAPI, msg tgbotapi.MessageConfig) { _, err := client.Send(msg) recordSentMessage(msg.ChatID, msg.Text, err) if err != nil { log.Println(err) } } // SetBotCommands registers slash command menus for private and group chats. func SetBotCommands(client *tgbotapi.BotAPI) { if client == nil { return } commands := []tgbotapi.BotCommand{ {Command: "start", Description: "Подключить Telegram-уведомления"}, {Command: "id", Description: "Показать ID чата"}, {Command: "info", Description: "Информация о привязке"}, {Command: "settings", Description: "Настройки контакта"}, {Command: "stop", Description: "Отключить уведомления"}, {Command: "help", Description: "Список команд"}, } if _, err := client.Request(tgbotapi.NewSetMyCommands(commands...)); err != nil { log.Println("telegram set commands:", err) } } func heartbeat() { ticker := time.NewTicker(time.Minute) defer ticker.Stop() for range ticker.C { markBotOnline("") } } func markBotOnline(lastErr string) { now := time.Now() username := "" if bot != nil { username = bot.Self.UserName } status := models.TelegramBotStatus{} models.DB().Where("name = ?", models.TelegramBotStatusMain). FirstOrCreate(&status, models.TelegramBotStatus{Name: models.TelegramBotStatusMain}) status.Username = username status.Online = true status.LastSeen = &now status.LastError = lastErr _ = models.DB().Save(&status).Error } func recordReceivedMessage(message *tgbotapi.Message, contactID *int64) { if message == nil || message.Chat == nil { return } username := "" if message.From != nil { username = message.From.UserName } record := models.TelegramBotMessage{ Direction: models.TelegramBotMessageReceived, ChatID: message.Chat.ID, ChatType: message.Chat.Type, Username: username, Text: message.Text, Command: message.Command(), ContactID: contactID, CreatedAt: time.Now(), } _ = models.DB().Create(&record).Error } func recordSentMessage(chatID int64, text string, sendErr error) { errorText := "" if sendErr != nil { errorText = sendErr.Error() } record := models.TelegramBotMessage{ Direction: models.TelegramBotMessageSent, ChatID: chatID, Text: text, Error: errorText, CreatedAt: time.Now(), } _ = models.DB().Create(&record).Error } func disableChatNotifications(chatID int64) (int64, error) { value := strconv.FormatInt(chatID, 10) result := models.DB().Model(&models.Contact{}). Where("kind IN ? AND value = ?", []string{"telegram_private", "telegram_group"}, value). Update("enabled", false) return result.RowsAffected, result.Error }