Files
worker/internal/tg/bot.go
Gleb Tv 2c884c5612
Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
refactor: adopt worker module path
2026-07-13 17:56:12 +03:00

412 строки
11 KiB
Go

// 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
}