Files
3x-ui/internal/web/service/tgbot/notices.go
T
DIMFLIX 15fee2a035 feat(tgbot): rebuild the Telegram bot around editable screens and inline lists (#6760)
The bot answered every action with a new message, so a chat filled with stale
menus whose buttons still acted, and long client or inbound lists were paged
through dozens of buttons. Each chat now has one live screen that is edited in
place: the menu is grouped into Server, Clients, Traffic and Maintenance, lists
open through inline search, the add-client flow starts from a draft card, QR
codes arrive as one labelled sheet, and repeated notices collapse into one with
a counter. A client's /start and /usage show only that client's own links and
usage, groups are refused, and the settings page warns when the bot's inline
mode is off. Admin topics, the indexed Telegram lookup and every locale,
Slovak included, carry over.
2026-10-24 12:39:56 +02:00

167 lines
4.6 KiB
Go

package tgbot
import (
"context"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
)
// Panel notifications (outage, failed login, backup) are ONE live message per
// (chat, kind): repeats edit it in place instead of burying the chat.
type noticeKey struct {
chatID int64
kind string
}
type noticeState struct {
msgID int
body string
count int
at time.Time
}
type noticeStore struct {
mu sync.Mutex
notices map[noticeKey]noticeState
}
var notices = &noticeStore{notices: map[noticeKey]noticeState{}}
func (s *noticeStore) get(key noticeKey) (noticeState, bool) {
s.mu.Lock()
defer s.mu.Unlock()
st, ok := s.notices[key]
return st, ok
}
func (s *noticeStore) put(key noticeKey, st noticeState) {
s.mu.Lock()
s.notices[key] = st
s.mu.Unlock()
}
func (s *noticeStore) reset() {
s.mu.Lock()
s.notices = map[noticeKey]noticeState{}
s.mu.Unlock()
}
// noticeRepeatWindow is how long a repeated event of the same kind keeps
// updating the live card instead of starting a new one.
const noticeRepeatWindow = 30 * time.Minute
// liveNotice sends or updates the chat's card for one kind of event; a repeat
// inside the window edits it and counts, so a storm reads as one numbered line.
func (t *Tgbot) liveNotice(chatID int64, kind, body string) {
if liveBot() == nil {
return
}
key := noticeKey{chatID: chatID, kind: kind}
st, ok := notices.get(key)
if ok && time.Since(st.at) < noticeRepeatWindow && st.msgID != 0 {
st.count++
st.at = time.Now()
st.body = body
text := t.noticeText(body, st.count)
if t.editNotice(chatID, st.msgID, text) {
notices.put(key, st)
return
}
// The card is gone or too old to edit: fall through and post a fresh one.
}
st = noticeState{body: body, count: 1, at: time.Now()}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
sc, err := liveBot().SendMessage(ctx, &telego.SendMessageParams{
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: t.noticeText(body, 1), ParseMode: "HTML", ReplyMarkup: t.hideButton(),
})
if err != nil {
logger.Warning("Failed to send a notice:", err)
return
}
st.msgID = sc.MessageID
notices.put(key, st)
}
// noticeText renders the event plus its repeat counter.
func (t *Tgbot) noticeText(body string, count int) string {
text := body
if count > 1 {
text += t.I18nBot("tgbot.notices.repeat", "Count=="+itoa(count), "Time=="+time.Now().Format("15:04:05"))
}
return text
}
// editNotice rewrites a live card. false means it could not be edited and the
// caller must post a new one.
func (t *Tgbot) editNotice(chatID int64, msgID int, text string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := liveBot().EditMessageText(ctx, &telego.EditMessageTextParams{
ChatID: tu.ID(chatID), MessageID: msgID, Text: text, ParseMode: "HTML",
ReplyMarkup: t.hideButton(),
})
if err == nil || isTelegramNotModifiedError(err) {
return true
}
logger.Debug("Notice not editable, posting a new one:", err)
return false
}
// dropNoticeFor forgets the card a hidden message belonged to, so the next
// event of that kind starts a fresh card.
func (t *Tgbot) dropNoticeFor(chatID int64, msgID int) {
notices.mu.Lock()
defer notices.mu.Unlock()
for key, st := range notices.notices {
if key.chatID == chatID && st.msgID == msgID {
delete(notices.notices, key)
}
}
}
// sendDocumentWithCaption uploads a file with its summary as caption: a document
// cannot hold a screen, so one message does the work of two.
func (t *Tgbot) sendDocumentWithCaption(chatID int64, data []byte, name, caption string) bool {
if liveBot() == nil || data == nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
_, err := liveBot().SendDocument(ctx, &telego.SendDocumentParams{
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID),
Document: tu.FileFromBytes(data, name),
Caption: caption,
ParseMode: "HTML",
ReplyMarkup: t.hideButton(),
})
if err != nil {
logger.Warning("Failed to upload a file with a caption:", err)
return false
}
return true
}
// trimCaption cuts a summary down to what a caption can carry, on a line break
// so a sentence is never left half-written.
func trimCaption(text string, limit int) string {
if limit <= 0 || len([]rune(text)) <= limit {
return text
}
runes := []rune(text)
cut := string(runes[:limit])
if idx := strings.LastIndex(cut, "\n"); idx > 0 {
cut = cut[:idx]
}
return strings.TrimRight(cut, "\r\n")
}