feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
411
internal/tg/bot.go
Обычный файл
411
internal/tg/bot.go
Обычный файл
@@ -0,0 +1,411 @@
|
||||
// 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"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/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
|
||||
}
|
||||
14
internal/tg/bot_test.go
Обычный файл
14
internal/tg/bot_test.go
Обычный файл
@@ -0,0 +1,14 @@
|
||||
package tg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTelegramAPIEndpoint(t *testing.T) {
|
||||
assert.Equal(t, tgbotapi.APIEndpoint, telegramAPIEndpoint(""))
|
||||
assert.Equal(t, "https://api.telegram.org/bot%s/%s", telegramAPIEndpoint("https://api.telegram.org"))
|
||||
assert.Equal(t, "https://proxy.example.com/telegram/bot%s/%s", telegramAPIEndpoint("https://proxy.example.com/telegram/"))
|
||||
}
|
||||
78
internal/tg/debug/main.go
Обычный файл
78
internal/tg/debug/main.go
Обычный файл
@@ -0,0 +1,78 @@
|
||||
// Package main provides functionality.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/config/database"
|
||||
_ "rsgit.ru/rsmon/rsmon/config/env"
|
||||
)
|
||||
|
||||
func main() {
|
||||
database.Init()
|
||||
creds, err := models.EnabledCredentialsByKind(models.CredentialKindTelegram)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
if len(creds) == 0 {
|
||||
log.Panic("telegram credential is not configured")
|
||||
}
|
||||
token, err := creds[0].GetSecret()
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
bot, err := tgbotapi.NewBotAPI(token)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
bot.Debug = true
|
||||
|
||||
log.Printf("Authorized on account %s", bot.Self.UserName)
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 60
|
||||
|
||||
updates := bot.GetUpdatesChan(u)
|
||||
|
||||
for update := range updates {
|
||||
if update.Message == nil { // ignore any non-Message updates
|
||||
continue
|
||||
}
|
||||
|
||||
if !update.Message.IsCommand() { // ignore any non-command Messages
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a new MessageConfig. We don't have text yet,
|
||||
// so we leave it empty.
|
||||
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "")
|
||||
|
||||
spew.Dump(update.Message)
|
||||
spew.Dump(update.Message.Chat)
|
||||
|
||||
switch update.Message.Chat.Type {
|
||||
case "private":
|
||||
// Extract the command from the Message.
|
||||
switch update.Message.Command() {
|
||||
case "start":
|
||||
msg.Text = "Для завершения добавления вида оповещений перейдите по ссылке https://rsmon.ru/contacts/new?kind=telegram&"
|
||||
case "help":
|
||||
msg.Text = "/start - добавление способа оповещений\n/list список способов оповещений для этого чата\n"
|
||||
default:
|
||||
msg.Text = "Неизвестная команда"
|
||||
}
|
||||
case "group":
|
||||
default:
|
||||
msg.Text = "Неизвестный тип чата: " + update.Message.Chat.Type
|
||||
}
|
||||
|
||||
if _, err := bot.Send(msg); err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user