Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
412 строки
11 KiB
Go
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
|
|
}
|