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.
This commit is contained in:
DIMFLIX
2026-10-24 13:39:56 +03:00
committed by GitHub
parent bc1b750e00
commit 15fee2a035
62 changed files with 7449 additions and 1945 deletions
+22
View File
@@ -0,0 +1,22 @@
package tgbot
// helpers.go holds the small shared primitives the screen layer needs, so the
// router never grows a second copy of them.
import (
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// xrayProcessOrNil returns the running Xray process, or nil; a panel whose Xray
// is down is a normal state the screens must render, not an error.
func xrayProcessOrNil() *xray.Process {
process := service.XrayProcess()
if process == nil || !process.IsRunning() {
return nil
}
return process
}
// panelVersion is the version string the menu shows.
func panelVersion() string { return config.GetPanelVersion() }
@@ -0,0 +1,131 @@
package tgbot
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
// I18nBot returns "" for a missing key, so a screen renders blank buttons without failing; that is how
// /start once answered with no menu. Every key a Go source asks for must exist.
var (
i18nBotKeyPattern = regexp.MustCompile(`I18nBot\(\s*"([^"]+)"`)
// Button labels go through btn(), which also calls I18nBot internally, so a
// broken button key is invisible to a scan of I18nBot calls alone.
btnKeyPattern = regexp.MustCompile(`\.btn(?:2)?\(\s*"([^"]+)"`)
// The category screen asks for "tgbot.messages.category_" + name.
categoryKeyPattern = regexp.MustCompile(`tgbot\.messages\.category_"`)
)
func TestEveryBotKeyExistsInTheTranslations(t *testing.T) {
root := repoRootForTest(t)
keys := map[string]string{} // key -> first file that asks for it
err := filepath.Walk(filepath.Join(root, "internal"), func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
return err
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
patterns := []*regexp.Regexp{i18nBotKeyPattern, btnKeyPattern}
for _, pattern := range patterns {
for _, m := range pattern.FindAllStringSubmatch(string(data), -1) {
key := m[1]
// Keys are also built at run time ("tgbot.screens." + kind); a literal ending in a separator is such
// a prefix, and the set it expands to is checked explicitly.
if strings.ContainsAny(key, "%$") || strings.HasSuffix(key, ".") || strings.HasSuffix(key, "_") {
continue
}
if _, seen := keys[key]; !seen {
keys[key] = strings.TrimPrefix(path, root+"/")
}
}
}
if categoryKeyPattern.MatchString(string(data)) {
for _, category := range []string{"server", "clients", "traffic", "maintenance"} {
keys["tgbot.messages.category_"+category] = strings.TrimPrefix(path, root+"/")
}
}
return nil
})
if err != nil {
t.Fatalf("scan sources: %v", err)
}
// Keys assembled from a prefix at run time; their full set is checked so the
// dynamic path cannot hide a missing entry.
for _, kind := range screenKinds {
keys["tgbot.screens."+kind] = "screens_art.go (screenKinds)"
}
files, err := filepath.Glob(filepath.Join(root, "internal/web/translation/*.json"))
if err != nil || len(files) == 0 {
t.Fatalf("no translation files found: %v", err)
}
for _, file := range files {
data, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
var bundle map[string]any
if err := json.Unmarshal(data, &bundle); err != nil {
t.Fatalf("parse %s: %v", file, err)
}
var missing []string
for key, source := range keys {
if !nestedKeyPresent(bundle, key) {
missing = append(missing, fmt.Sprintf("%s (%s)", key, source))
}
}
if len(missing) > 0 {
sort.Strings(missing)
t.Errorf("%s is missing %d key(s) the bot asks for:\n %s",
filepath.Base(file), len(missing), strings.Join(missing, "\n "))
}
}
}
// nestedKeyPresent walks a dotted key through the nested translation objects.
func nestedKeyPresent(bundle map[string]any, key string) bool {
var current any = bundle
for _, part := range strings.Split(key, ".") {
obj, ok := current.(map[string]any)
if !ok {
return false
}
current, ok = obj[part]
if !ok {
return false
}
}
return true
}
// repoRootForTest walks up from the test's working directory to the module root.
func repoRootForTest(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("module root not found")
}
dir = parent
}
}
+166
View File
@@ -0,0 +1,166 @@
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")
}
+156
View File
@@ -0,0 +1,156 @@
package tgbot
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
)
// noticeServer records sendMessage/sendDocument/editMessageText with the visible body, so a test can
// assert how many messages an event burst produced.
func noticeServer(t *testing.T) (*httptest.Server, func() []apiCall) {
t.Helper()
var mu sync.Mutex
var calls []apiCall
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
sent := extractSentBody(t, r)
mu.Lock()
calls = append(calls, apiCall{Method: method, Payload: map[string]any{
"text": sent.Text, "chat_id": float64(sent.ChatID),
}})
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
switch method {
case "sendMessage", "sendDocument", "sendPhoto":
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
"message_id": 77, "date": 0, "chat": map[string]any{"id": sent.ChatID, "type": "private"},
}})
default:
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
"message_id": 77, "date": 0, "chat": map[string]any{"id": sent.ChatID, "type": "private"},
}})
}
}))
return srv, func() []apiCall {
mu.Lock()
defer mu.Unlock()
return append([]apiCall(nil), calls...)
}
}
// Regression test: the same event twice must edit its live card, not post a
// second message — a flapping outbound used to bury the chat.
func TestRepeatedEventEditsItsLiveCard(t *testing.T) {
srv, calls := noticeServer(t)
swapTestBot(t, srv.URL)
defer srv.Close()
setTestAdmins(t, 1)
notices.reset()
tb := initReportDB(t)
event := eventbus.Event{Type: eventbus.EventOutboundDown, Source: "nl-node"}
tb.liveNotice(1, "outbound_down:nl-node", "Outbound nl-node is DOWN")
tb.liveNotice(1, "outbound_down:nl-node", "Outbound nl-node is DOWN")
tb.liveNotice(1, "outbound_down:nl-node", "Outbound nl-node is DOWN")
got := calls()
if n := countMethod(got, "sendMessage"); n != 1 {
t.Errorf("sendMessage = %d, want 1: repeats must edit, not post (calls: %v)", n, methods(got))
}
if n := countMethod(got, "editMessageText"); n != 2 {
t.Errorf("editMessageText = %d, want 2: each repeat redraws the card", n)
}
_ = event
}
// A different kind of event gets its own card: they tell unrelated stories.
func TestDifferentEventKindsKeepSeparateCards(t *testing.T) {
srv, calls := noticeServer(t)
swapTestBot(t, srv.URL)
defer srv.Close()
setTestAdmins(t, 1)
notices.reset()
tb := initReportDB(t)
tb.liveNotice(1, "outbound_down:a", "A is DOWN")
tb.liveNotice(1, "xray_crash:x", "Xray crashed")
if n := countMethod(calls(), "sendMessage"); n != 2 {
t.Errorf("sendMessage = %d, want 2: unrelated events need their own cards", n)
}
}
// The backup must arrive as one message: the file carries the summary.
func TestBackupArrivesAsOneMessageWithACaption(t *testing.T) {
srv, calls := noticeServer(t)
swapTestBot(t, srv.URL)
defer srv.Close()
notices.reset()
tb := initReportDB(t)
tb.sendDocumentWithCaption(1, []byte("db-bytes"), "x-ui.db", "host\nbackup time")
got := calls()
sends := 0
for _, c := range got {
if c.Method == "sendMessage" || c.Method == "sendDocument" {
sends++
}
}
if sends != 1 {
t.Fatalf("messages = %d, want 1 (calls: %v)", sends, methods(got))
}
if countMethod(got, "sendDocument") != 1 {
t.Error("the backup did not travel as a document")
}
}
// A caption longer than Telegram's limit is cut on a line break, never mid-line.
func TestTrimCaptionCutsOnALineBreak(t *testing.T) {
body := strings.Repeat("line of a long report\n", 200)
got := trimCaption(body, botCaptionLimit)
if len([]rune(got)) > botCaptionLimit {
t.Fatalf("caption is %d runes, want at most %d", len([]rune(got)), botCaptionLimit)
}
if strings.HasSuffix(got, "\n") || strings.HasSuffix(got, "\r") {
t.Errorf("caption ends on a dangling line break: %q", got[max(0, len(got)-20):])
}
}
// Hiding a notice forgets its card, so the next event does not edit a message
// the user just deleted.
func TestHiddenNoticeIsForgotten(t *testing.T) {
srv, _ := noticeServer(t)
swapTestBot(t, srv.URL)
defer srv.Close()
setTestAdmins(t, 1)
notices.reset()
tb := initReportDB(t)
tb.liveNotice(1, "outbound_down:a", "A is DOWN")
key := noticeKey{chatID: 1, kind: "outbound_down:a"}
if _, ok := notices.get(key); !ok {
t.Fatal("the live card was not remembered")
}
tb.dropNoticeFor(1, 77)
if _, ok := notices.get(key); ok {
t.Error("a hidden notice is still remembered; the next event would edit a deleted message")
}
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
var _ = io.Discard
+477
View File
@@ -0,0 +1,477 @@
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"
)
// botCaptionLimit is Telegram's caption ceiling; a longer caption is a hard API
// error, so a screen pages its body instead of risking a lost render.
const botCaptionLimit = 1024
// screen is the bot's only chat surface: one photo message whose caption and
// keyboard are edited on every action, so a chat never grows a second menu.
type screen struct {
msgID int
kind string
// root marks a screen that IS the viewer's top level: it must not offer a
// back button, because there is nothing above it to go back to.
root bool
// qrPicture is the sheet of QR codes a "qr" screen shows instead of the
// shared avatar: a code belongs to one client, so it is rendered per screen.
qrPicture *telego.InputFile
// qrSource is the subscription URL the sheet was built from.
qrSource string
// attachment is a picture built for this one screen (a QR). It is uploaded
// on send and remembered by content, so an edit reuses the same picture.
attachment *telego.InputFile
photo string // file_id to reuse for an edit; empty means upload or text
hasPhoto bool // whether the tracked message carries a picture
header string
body string
pages []string
page int
rows [][]telego.InlineKeyboardButton
markup *telego.InlineKeyboardMarkup
text string
pageMax int
}
// screenStore keeps the live screen per chat. The msgID inside it belongs to
// that chat, so a stale keyboard cannot address another chat's screen.
type screenStore struct {
mu sync.Mutex
chats map[int64]*screen
}
var screens = &screenStore{chats: map[int64]*screen{}}
func (s *screenStore) get(chatID int64) (*screen, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sc, ok := s.chats[chatID]
return sc, ok
}
func (s *screenStore) put(chatID int64, sc *screen) {
s.mu.Lock()
s.chats[chatID] = sc
s.mu.Unlock()
}
func (s *screenStore) clear(chatID int64) {
s.mu.Lock()
delete(s.chats, chatID)
s.mu.Unlock()
}
func (s *screenStore) reset() {
s.mu.Lock()
s.chats = map[int64]*screen{}
s.mu.Unlock()
}
// newScreen assembles a screen: breadcrumb header, body paged into
// caption-sized chunks, and the caller's keyboard rows.
// rootScreen builds the viewer's own top level, so the way-out invariant does
// not apply: a back button there would lead to the same screen.
func (t *Tgbot) rootScreen(kind, body string, rows ...[]telego.InlineKeyboardButton) *screen {
sc := t.newScreen(kind, body, append(rows, t.backRow())...)
sc.root = true
sc.rows = stripWayOut(sc.rows)
if sc.markup != nil {
sc.markup = tu.InlineKeyboard(sc.rows...)
}
return sc
}
// stripWayOut removes the home row. Used only when building a root screen, where
// a way out would be a link to itself.
func stripWayOut(rows [][]telego.InlineKeyboardButton) [][]telego.InlineKeyboardButton {
out := make([][]telego.InlineKeyboardButton, 0, len(rows))
for _, row := range rows {
kept := make([]telego.InlineKeyboardButton, 0, len(row))
for _, btn := range row {
if btn.CallbackData != cbHome {
kept = append(kept, btn)
}
}
if len(kept) > 0 {
out = append(out, kept)
}
}
return out
}
func (t *Tgbot) newScreen(kind, body string, rows ...[]telego.InlineKeyboardButton) *screen {
// One header builder for every screen, so none can open as a dead end.
// Terminal callbacks beyond "home" are registered by the caller.
rows = t.ensureWayOut(rows)
header := ""
if crumb := t.breadcrumb(kind); crumb != "" {
header = "<b>" + crumb + "</b>\r\n"
}
// Bodies arrive with either line ending; paging splits on CRLF, so a body
// built with bare LF would silently stay one over-long page.
body = strings.ReplaceAll(body, "\r\n", "\n")
body = strings.ReplaceAll(body, "\n", "\r\n")
pages := pageMessage(body, botCaptionLimit-len(header))
if len(pages) == 0 {
pages = []string{""}
}
return &screen{kind: kind, header: header, body: body, pages: pages, rows: rows, pageMax: len(pages)}
}
// renderScreen is the single entry point for drawing a screen: EDIT first, and
// replace (send then delete) only when the edit is genuinely impossible.
func (t *Tgbot) renderScreen(chatID int64, sc *screen) {
if sc == nil || liveBot() == nil {
return
}
// A QR screen carries its own picture: the codes being shown are what the
// screen was opened for, and a file beside it is just clutter.
if sc.qrPicture != nil {
sc.hasPhoto, sc.attachment = true, sc.qrPicture
} else {
t.attachArt(sc)
}
sc.text = sc.header + sc.pages[min(sc.page, len(sc.pages)-1)]
rows := sc.rows
if row := t.pagerRow(sc); row != nil {
rows = append(append([][]telego.InlineKeyboardButton{}, sc.rows...), row)
}
sc.markup = tu.InlineKeyboard(rows...)
if prev, ok := t.screens().get(chatID); ok && prev.msgID != 0 {
if t.editScreen(chatID, prev, sc) {
sc.msgID = prev.msgID
t.screens().put(chatID, sc)
return
}
}
t.replaceScreen(chatID, sc)
}
// attachArt pins the screen's picture (avatar, else the always-available black
// tile), so a screen is always a photo message and edits can go through media.
func (t *Tgbot) attachArt(sc *screen) {
// A stale cache is refreshed off the render path, so a picture uploaded to
// @BotFather shows up without a panel restart and no screen waits on it.
t.ensureScreenArt()
sc.hasPhoto = true
if id, ok := artFileID(sc.kind); ok {
sc.photo = id
return
}
sc.photo = ""
}
// screens keeps one call site for the store, so a test can swap it.
func (t *Tgbot) screens() *screenStore { return screens }
// editScreen edits the tracked message into the new screen. false means the
// caller must replace it: the message is gone, too old, or of another type.
func (t *Tgbot) editScreen(chatID int64, prev *screen, next *screen) bool {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var err error
switch {
case next.hasPhoto && next.photo != "":
_, err = liveBot().EditMessageMedia(ctx, &telego.EditMessageMediaParams{
ChatID: tu.ID(chatID),
MessageID: prev.msgID,
Media: &telego.InputMediaPhoto{
Type: "photo", Media: tu.FileFromID(next.photo), Caption: next.text, ParseMode: "HTML",
},
ReplyMarkup: next.markup,
})
case next.attachment != nil:
_, err = liveBot().EditMessageMedia(ctx, &telego.EditMessageMediaParams{
ChatID: tu.ID(chatID),
MessageID: prev.msgID,
Media: &telego.InputMediaPhoto{
Type: "photo", Media: *next.attachment, Caption: next.text, ParseMode: "HTML",
},
ReplyMarkup: next.markup,
})
case next.hasPhoto:
// No file_id yet (the avatar was never read): upload the fallback tile.
upload, ok := artUpload(next.kind)
if !ok {
return false
}
_, err = liveBot().EditMessageMedia(ctx, &telego.EditMessageMediaParams{
ChatID: tu.ID(chatID),
MessageID: prev.msgID,
Media: &telego.InputMediaPhoto{
Type: "photo", Media: upload, Caption: next.text, ParseMode: "HTML",
},
ReplyMarkup: next.markup,
})
case prev.hasPhoto:
// A photo message cannot become a text one: drop the picture and let
// its caption carry the screen.
_, err = liveBot().EditMessageCaption(ctx, &telego.EditMessageCaptionParams{
ChatID: tu.ID(chatID), MessageID: prev.msgID,
Caption: next.text, ParseMode: "HTML", ReplyMarkup: next.markup,
})
default:
_, err = liveBot().EditMessageText(ctx, &telego.EditMessageTextParams{
ChatID: tu.ID(chatID),
MessageID: prev.msgID,
Text: next.text,
ParseMode: "HTML",
ReplyMarkup: next.markup,
})
}
if err == nil || isTelegramNotModifiedError(err) {
return true
}
logger.Debug("Screen not editable, replacing:", err)
return false
}
// replaceScreen sends the new screen BEFORE deleting the old one: with a slow
// send, delete-first leaves the chat empty and reads as broken.
func (t *Tgbot) replaceScreen(chatID int64, sc *screen) int {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
prev, hadPrev := t.screens().get(chatID)
var sent *telego.Message
var err error
switch {
case sc.hasPhoto && sc.photo != "":
sent, err = liveBot().SendPhoto(ctx, &telego.SendPhotoParams{
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Photo: tu.FileFromID(sc.photo),
Caption: sc.text, ParseMode: "HTML", ReplyMarkup: sc.markup,
})
case sc.attachment != nil:
sent, err = liveBot().SendPhoto(ctx, &telego.SendPhotoParams{
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Photo: *sc.attachment,
Caption: sc.text, ParseMode: "HTML", ReplyMarkup: sc.markup,
})
case sc.hasPhoto:
upload, ok := artUpload(sc.kind)
if !ok {
return 0
}
sent, err = liveBot().SendPhoto(ctx, &telego.SendPhotoParams{
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Photo: upload,
Caption: sc.text, ParseMode: "HTML", ReplyMarkup: sc.markup,
})
default:
sent, err = liveBot().SendMessage(ctx, &telego.SendMessageParams{
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: sc.text, ParseMode: "HTML", ReplyMarkup: sc.markup,
})
}
if err != nil {
logger.Warning("Failed to send a screen:", err)
return 0
}
if hadPrev && prev.msgID != 0 {
t.deleteOwnMessage(chatID, prev.msgID)
}
sc.msgID = sent.MessageID
// Telegram hands back the file_id of the uploaded tile: remembering it keeps
// every later edit a cheap media edit instead of another upload.
if sc.hasPhoto && sc.photo == "" && len(sent.Photo) > 0 {
sc.photo = sent.Photo[len(sent.Photo)-1].FileID
cacheArtFileID(sc.kind, sc.photo)
}
t.screens().put(chatID, sc)
return sent.MessageID
}
// ensureBackRow guarantees every screen can be left (a screen with only its own
// actions traps the user) and checks by callback, not label.
// wayOutNotes are callbacks that already return to a root menu, so a screen
// carrying one must not gain a second, confusing back row.
var wayOutNotes = []string{cbHome}
// terminalCallbacks extends wayOutNotes: an engine may register the callbacks
// its own root menu answers to. Guarded because tests and the panel install in.
var (
terminalCallbacksMu sync.RWMutex
terminalCallbacks []string
)
func registerTerminalCallbacks(data ...string) {
terminalCallbacksMu.Lock()
terminalCallbacks = append(terminalCallbacks, data...)
terminalCallbacksMu.Unlock()
}
// hasWayOut reports whether any button on the screen is a route back to a root
// menu, so that a screen offering one is not a dead end.
func hasWayOut(rows [][]telego.InlineKeyboardButton) bool {
terminalCallbacksMu.RLock()
known := append(append([]string{}, wayOutNotes...), terminalCallbacks...)
terminalCallbacksMu.RUnlock()
for _, row := range rows {
for _, btn := range row {
for _, want := range known {
if btn.CallbackData == want {
return true
}
}
}
}
return false
}
// ensureWayOut appends the home row to a screen that offers no exit. This is
// the one place screens are built, so no new screen can forget it.
func (t *Tgbot) ensureWayOut(rows [][]telego.InlineKeyboardButton) [][]telego.InlineKeyboardButton {
if hasWayOut(rows) {
return rows
}
return append(append([][]telego.InlineKeyboardButton{}, rows...), t.backRow())
}
// breadcrumb names the screen's place in the tree, so a user who was away can
// see where a stale keyboard left them.
func (t *Tgbot) breadcrumb(kind string) string {
key := "tgbot.screens." + kind
crumb := t.I18nBot(key)
if crumb == "" || crumb == key {
return ""
}
return t.I18nBot("tgbot.screens.panel") + " › " + crumb
}
// pagerRow adds the page controls when the body did not fit one caption; the
// arrows are callback data, never a bare icon-only target.
func (t *Tgbot) pagerRow(sc *screen) []telego.InlineKeyboardButton {
if sc.pageMax <= 1 {
return nil
}
prev := tu.InlineKeyboardButton("‹").WithCallbackData("pg:prev")
if sc.page <= 0 {
prev = tu.InlineKeyboardButton("·").WithCallbackData("pg:none")
}
next := tu.InlineKeyboardButton("›").WithCallbackData("pg:next")
if sc.page >= sc.pageMax-1 {
next = tu.InlineKeyboardButton("·").WithCallbackData("pg:none")
}
return tu.InlineKeyboardRow(prev, tu.InlineKeyboardButton(t.pageLabel(sc.page, sc.pageMax)).WithCallbackData("pg:none"), next)
}
// pageLabel renders "Стр. 2 из 5" through the locale, never a raw "2/5".
func (t *Tgbot) pageLabel(page, pages int) string {
return t.I18nBot("tgbot.buttons.page", "Page=="+itoa(page+1), "Pages=="+itoa(pages))
}
// showPage re-renders the stored screen at another page of its body.
func (t *Tgbot) showPage(chatID int64, page int) {
sc, ok := t.screens().get(chatID)
if !ok || sc.pageMax <= 1 || page < 0 || page >= sc.pageMax {
return
}
sc.page = page
sc.pages = pageMessage(sc.body, botCaptionLimit-len(sc.header))
t.renderScreen(chatID, sc)
}
// deleteScreenMessage is best effort: the user may have removed the screen, or
// Telegram may refuse past the 48-hour window. Neither is worth operator noise.
func (t *Tgbot) deleteScreenMessage(chatID int64, msgID int) { t.deleteOwnMessage(chatID, msgID) }
// deleteIncoming removes a message the user sent: the wizard's text replies and
// the pilot taps must not pile up in the chat.
func (t *Tgbot) deleteIncoming(message *telego.Message) {
if message == nil || liveBot() == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := liveBot().DeleteMessage(ctx, &telego.DeleteMessageParams{ChatID: tu.ID(message.Chat.ID), MessageID: message.MessageID}); err != nil {
logger.Debug("Cannot delete a user message:", err)
}
}
// deleteIncomingID removes one of the user's own messages by id, for flows that
// hold ids rather than message structs (the broadcast's composing messages).
func (t *Tgbot) deleteIncomingID(chatID int64, messageID int) {
if messageID == 0 {
return
}
t.deleteOwnMessage(chatID, messageID)
}
// deleteOwnMessage removes one of our messages without surfacing a failure: the
// user may have deleted it, and Telegram refuses past 48 hours.
func (t *Tgbot) deleteOwnMessage(chatID int64, msgID int) {
if msgID == 0 || liveBot() == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := liveBot().DeleteMessage(ctx, &telego.DeleteMessageParams{ChatID: tu.ID(chatID), MessageID: msgID}); err != nil {
logger.Debug("Cannot delete a bot message:", err)
}
}
// hideButton is the keyboard of a non-screen message: one tap clears it.
func (t *Tgbot) hideButton() *telego.InlineKeyboardMarkup {
return hideButtonMarkup()
}
// hideButtonMarkup is the hide keyboard for callers with no Tgbot (e.g. a
// broadcast copy); the label falls back to English when the key cannot resolve.
func hideButtonMarkup() *telego.InlineKeyboardMarkup {
label := (&Tgbot{}).I18nBot("tgbot.buttons.hide")
if strings.TrimSpace(label) == "" {
label = "🙈 Hide"
}
return tu.InlineKeyboard(tu.InlineKeyboardRow(
tu.InlineKeyboardButton(label).WithCallbackData("hide"),
))
}
// answerSilent acknowledges a callback without a toast, for taps whose only
// effect is visible in the message itself.
func (t *Tgbot) answerSilent(callbackID string) {
if liveBot() == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := liveBot().AnswerCallbackQuery(ctx, &telego.AnswerCallbackQueryParams{CallbackQueryID: callbackID}); err != nil {
logger.Debug("Answer callback query failed:", err)
}
}
func itoa(v int) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// I18nBotStatic is gone; labels are built on the Tgbot receiver.
@@ -0,0 +1,136 @@
package tgbot
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/png"
"strconv"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
)
// The QR screen lays every code (subscription, JSON, up to five app links) on
// ONE picture instead of one document each — up to seven files before.
// subscriptionQRCode is one code on the sheet, in the order it is drawn.
type subscriptionQRCode struct {
content string
// kind selects the legend line: "sub", "json" or "link".
kind string
// app numbers an app link in the legend.
app int
}
// subscriptionQRCodes collects every code for a client in the order Telegram
// used to deliver them: subscription, JSON subscription, then the app links.
func (t *Tgbot) subscriptionQRCodes(email, subURL string) []subscriptionQRCode {
if subURL == "" {
return nil
}
codes := []subscriptionQRCode{{content: subURL, kind: "sub"}}
if _, subJSON, err := t.buildSubscriptionURLs(email); err == nil && subJSON != "" {
codes = append(codes, subscriptionQRCode{content: subJSON, kind: "json"})
}
if links, err := t.clientSubLinks(email, subURL); err == nil {
for i, link := range links {
if i >= qrSheetMaxLinks {
break
}
codes = append(codes, subscriptionQRCode{content: link, kind: "link", app: i + 1})
}
}
if len(codes) > qrSheetMaxCodes {
codes = codes[:qrSheetMaxCodes]
}
return codes
}
const (
qrTileSize = 320
qrSheetPad = 24
qrSheetMaxCols = 3
// The sheet stays inside Telegram's photo size limits while covering what
// the original sent as separate documents.
qrSheetMaxCodes = 6
qrSheetMaxLinks = 4
)
// qrCodeLegend numbers the codes in the picture's own order, so a code can be
// told apart without reading it: the tiles carry no text, they are scanned.
func (t *Tgbot) qrCodeLegend(codes []subscriptionQRCode) string {
if len(codes) == 0 {
return ""
}
var body bytes.Buffer
body.WriteString("\r\n")
body.WriteString(t.I18nBot("tgbot.messages.qrLegendHeader"))
body.WriteString("\r\n")
for i, code := range codes {
number := strconv.Itoa(i + 1)
switch code.kind {
case "json":
body.WriteString(t.I18nBot("tgbot.messages.qrLegendJSON", "Code=="+number))
case "link":
body.WriteString(t.I18nBot("tgbot.messages.qrLegendLink",
"Code=="+number, "App=="+strconv.Itoa(code.app)))
default:
body.WriteString(t.I18nBot("tgbot.messages.qrLegendSubscription", "Code=="+number))
}
body.WriteString("\r\n")
}
return body.String()
}
// qrSheetPicture draws every code onto one picture in the legend's order and
// returns it as an uploadable file; nil means nothing could be rendered.
func qrSheetPicture(codes []subscriptionQRCode) *telego.InputFile {
tiles := make([]image.Image, 0, len(codes))
for _, code := range codes {
codePNG, err := qrPNG(code.content, qrTileSize)
if err != nil {
logger.Warning("Failed to render a subscription QR:", err)
continue
}
img, err := png.Decode(bytes.NewReader(codePNG))
if err != nil {
logger.Warning("Failed to decode a subscription QR:", err)
continue
}
tiles = append(tiles, img)
}
if len(tiles) == 0 {
return nil
}
cols := len(tiles)
if cols > qrSheetMaxCols {
cols = qrSheetMaxCols
}
rows := (len(tiles) + cols - 1) / cols
width := cols*qrTileSize + (cols+1)*qrSheetPad
height := rows*qrTileSize + (rows+1)*qrSheetPad
// The codes must keep their light background to stay scannable, so the sheet
// is white rather than the dark tone the screens use.
sheet := image.NewRGBA(image.Rect(0, 0, width, height))
draw.Draw(sheet, sheet.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
for i, tile := range tiles {
x := qrSheetPad + (i%cols)*(qrTileSize+qrSheetPad)
y := qrSheetPad + (i/cols)*(qrTileSize+qrSheetPad)
draw.Draw(sheet, image.Rect(x, y, x+qrTileSize, y+qrTileSize), tile, tile.Bounds().Min, draw.Src)
}
var buf bytes.Buffer
if err := png.Encode(&buf, sheet); err != nil {
logger.Warning("Failed to encode the QR sheet:", err)
return nil
}
file := tu.FileFromBytes(buf.Bytes(), "qr.png")
return &file
}
@@ -0,0 +1,383 @@
package tgbot
import (
"context"
"encoding/json"
"fmt"
"html"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
"github.com/skip2/go-qrcode"
)
func qrPNG(content string, size int) ([]byte, error) {
if size <= 0 {
size = 256
}
return qrcode.Encode(content, qrcode.Medium, size)
}
// The client card and its screens (limits, expiry, IP log, TG binding, links,
// QR) all redraw the same tracked message, so none leaves the previous behind.
// clientScreenBody is the shared header of every per-client screen: a user who
// tapped through four levels still sees which client they are on.
func (t *Tgbot) clientScreenBody(email, title string) string {
body := "<b>" + html.EscapeString(email) + "</b>\n"
if title != "" {
body += html.EscapeString(title) + "\n\n"
}
return body
}
// screenClientIps draws the IP log with refresh and clear.
func (t *Tgbot) screenClientIps(chatID int64, email string) {
body := t.clientScreenBody(email, "")
ips, err := t.inboundService.GetInboundClientIps(email)
if err != nil || len(ips) == 0 {
body += t.I18nBot("tgbot.noIpRecord")
} else {
body += t.I18nBot("tgbot.messages.ips", "IPs=="+html.EscapeString(formatIPLog(ips)))
}
body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
rows := [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", t.encodeQuery("ips_refresh "+email))),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.clearIPs", t.encodeQuery("clear_ips "+email))),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))),
t.backRow(),
}
t.renderScreen(chatID, t.newScreen("client", body, rows...))
}
// formatIPLog renders the stored IP log, which is either a list of addresses or
// a list of {ip, timestamp} objects depending on when it was written.
func formatIPLog(raw string) string {
type ipWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var entries []ipWithTimestamp
if json.Unmarshal([]byte(raw), &entries) == nil && len(entries) > 0 {
lines := make([]string, 0, len(entries))
for _, item := range entries {
if item.IP == "" {
continue
}
if item.Timestamp > 0 {
lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05")))
continue
}
lines = append(lines, item.IP)
}
if len(lines) > 0 {
return strings.Join(lines, "\n")
}
}
var plain []string
if json.Unmarshal([]byte(raw), &plain) == nil && len(plain) > 0 {
return strings.Join(plain, "\n")
}
return raw
}
// screenClientTG draws a client's Telegram binding. The user picker is the one
// reply keyboard kept: the API has no inline way to request a user.
func (t *Tgbot) screenClientTG(chatID int64, email string, withPicker bool) {
traffic, client, err := t.inboundService.GetClientByEmail(email)
if err != nil || client == nil {
t.sendNotice(chatID, t.I18nBot("tgbot.noResult"))
return
}
tgID := t.I18nBot("tgbot.noValue")
if client.TgID != 0 {
tgID = strconv.FormatInt(client.TgID, 10)
}
body := t.clientScreenBody(email, "")
body += t.I18nBot("tgbot.messages.TGUser", "TelegramID=="+tgID)
body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
rows := [][]telego.InlineKeyboardButton{}
if withPicker {
rows = append(rows, tu.InlineKeyboardRow(t.btn("tgbot.buttons.selectTGUser", t.encodeQuery("tgid_pick "+email))))
}
rows = append(rows,
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", t.encodeQuery("tgid_refresh "+email))),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.removeTGUser", t.encodeQuery("tgid_remove "+email))),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))),
t.backRow(),
)
t.renderScreen(chatID, t.newScreen("client", body, rows...))
if withPicker {
t.sendTGPicker(chatID, traffic.Id)
}
}
// sendTGPicker posts the native user picker — the only way the API asks for a
// user. It is a real message, so it carries the hide button and is removed later.
func (t *Tgbot) sendTGPicker(chatID int64, trafficID int) {
if liveBot() == nil {
return
}
t.sendNotice(chatID, t.I18nBot("tgbot.buttons.selectOneTGUser"))
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
request := telego.KeyboardButtonRequestUsers{RequestID: int32(trafficID), UserIsBot: new(bool)}
keyboard := tu.Keyboard(
tu.KeyboardRow(tu.KeyboardButton(t.I18nBot("tgbot.buttons.selectTGUser")).WithRequestUsers(&request)),
tu.KeyboardRow(tu.KeyboardButton(t.I18nBot("tgbot.buttons.closeKeyboard"))),
).WithIsPersistent().WithResizeKeyboard()
_, _ = liveBot().SendMessage(ctx, &telego.SendMessageParams{
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: t.I18nBot("tgbot.buttons.selectOneTGUser"), ReplyMarkup: keyboard,
})
}
// ownSubscriptionOnly reports whether the screen is the client's own single
// subscription, where admin-only controls must stay off (reached via "Links").
func (t *Tgbot) ownSubscriptionOnly(chatID int64, email string) bool {
if checkAdmin(chatID) {
return false
}
traffics, err := t.inboundService.GetClientTrafficTgBot(chatID)
if err != nil {
return false
}
return len(traffics) == 1 && traffics[0].Email == email
}
// screenClientLinks draws the subscription and the links of a client.
func (t *Tgbot) screenClientLinks(chatID int64, email string) {
subURL, subJSON, err := t.buildSubscriptionURLs(email)
if err != nil {
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
body := t.clientScreenBody(email, "")
body += t.I18nBot("tgbot.messages.subURL", "URL=="+html.EscapeString(subURL))
if subJSON != "" {
body += t.I18nBot("tgbot.messages.jsonURL", "URL=="+html.EscapeString(subJSON))
}
body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
rows := [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.buttons.individualLinks", t.encodeQuery("client_individual_links "+email))),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.qrCode", t.encodeQuery("client_qr_links "+email))),
}
// A client looking at its own subscription has nothing behind the admin's
// card: it only repeats the usage already on its home screen.
if !t.ownSubscriptionOnly(chatID, email) {
rows = append(rows, tu.InlineKeyboardRow(
t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))))
}
rows = append(rows, t.backRow())
t.renderScreen(chatID, t.newScreen("links", body, rows...))
}
// screenIndividualLinks renders the per-app links of the subscription as one
// (paged) message instead of one message per app.
func (t *Tgbot) screenIndividualLinks(chatID int64, email string) {
subURL, _, err := t.buildSubscriptionURLs(email)
if err != nil {
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
links, err := t.clientSubLinks(email, subURL)
if err != nil || len(links) == 0 {
t.sendNotice(chatID, t.I18nBot("tgbot.noResult"))
return
}
var body strings.Builder
body.WriteString(t.clientScreenBody(email, t.I18nBot("tgbot.buttons.individualLinks")))
for _, link := range links {
body.WriteString("<code>" + html.EscapeString(link) + "</code>\n")
}
t.renderScreen(chatID, t.newScreen("links", body.String(), t.individualLinksRows()...))
}
// individualLinksRows is the individual-links screen's keyboard: exactly one
// control, the shared back row, since a second "Links" button was a duplicate.
func (t *Tgbot) individualLinksRows() [][]telego.InlineKeyboardButton {
return [][]telego.InlineKeyboardButton{t.backRow()}
}
// screenClientQR renders the subscription QR as the screen picture and leaves
// the individual QRs to a document, so one screen covers the tap.
func (t *Tgbot) screenClientQR(chatID int64, email string) {
subURL, _, err := t.buildSubscriptionURLs(email)
if err != nil {
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
body := t.clientScreenBody(email, t.I18nBot("tgbot.answers.qrCodeForClient", "Email=="+email))
body += t.I18nBot("tgbot.messages.subURL", "URL=="+html.EscapeString(subURL))
rows := [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.buttons.individualLinks", t.encodeQuery("client_individual_links "+email))),
t.backRow(),
}
// One picture carrying every code the bot once sent as separate documents;
// tiles carry no text (they are scanned), so the caption legend numbers them.
codes := t.subscriptionQRCodes(email, subURL)
if len(codes) == 0 {
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
sc := t.newScreen("qr", body+t.qrCodeLegend(codes), rows...)
sc.qrPicture = qrSheetPicture(codes)
sc.qrSource = subURL
if sc.qrPicture == nil {
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
t.renderScreen(chatID, sc)
}
// renderPickerOnClient redraws the client card with a picker's rows appended, so
// tapping a limit opens its value list on the same tracked screen.
func (t *Tgbot) renderPickerOnClient(chatID int64, email string, picker [][]telego.InlineKeyboardButton) {
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
if err != nil || traffic == nil {
t.sendNotice(chatID, t.I18nBot("tgbot.noResult"))
return
}
rows := append([][]telego.InlineKeyboardButton{}, picker...)
rows = append(rows, t.backRow())
body := t.clientScreenBody(email, t.clientInfoMsg(traffic, true, true, true, true, true, false))
t.renderScreen(chatID, t.newScreen("client", body, rows...))
}
// renderDraftPicker redraws the add-client draft with a value picker's rows
// appended: the wizard's own card stays visible while a value is chosen.
func (t *Tgbot) renderDraftPicker(chatID int64, draft *clientDraft, picker [][]telego.InlineKeyboardButton) {
rows := append([][]telego.InlineKeyboardButton{}, t.getCommonClientButtons(draft)...)
rows = append(rows, picker...)
t.renderScreen(chatID, t.newScreen("wizard", t.BuildClientDraftMessage(draft), rows...))
}
// adoptScreen marks the message the user is looking at as this chat's screen,
// so the next render edits it instead of stacking a second screen under it.
func (t *Tgbot) adoptScreen(chatID int64, msgID int) {
if msgID == 0 {
return
}
if _, ok := t.screens().get(chatID); ok {
return
}
t.screens().put(chatID, &screen{msgID: msgID})
}
// clientTrafficID resolves the traffic row id a client's TG-user picker needs.
// A missing row is 0: the caller's screen already reports the unknown client.
func (t *Tgbot) clientTrafficID(email string) int {
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
if err != nil || traffic == nil {
return 0
}
return traffic.Id
}
// sendNoticeNoKeyboard reports a result and drops the client picker's reply
// keyboard in one message: this is the only place it is torn down.
func (t *Tgbot) sendNoticeNoKeyboard(chatID int64, text string) {
if liveBot() == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, err := liveBot().SendMessage(ctx, &telego.SendMessageParams{
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: text, ParseMode: "HTML",
ReplyMarkup: tu.ReplyKeyboardRemove(),
})
if err != nil {
logger.Warning("Failed to send a notice:", err)
}
}
// wizardPrompt asks for a text value on the draft screen itself: the wizard owns
// one message, so the question replaces the card; the reply keyboard is untouched.
func (t *Tgbot) wizardPrompt(chatID int64, draft *clientDraft, prompt string) {
rows := append([][]telego.InlineKeyboardButton{}, t.getCommonClientButtons(draft)...)
rows = append(rows, tu.InlineKeyboardRow(t.btn("tgbot.buttons.use_default", "add_client_default_info")))
t.renderScreen(chatID, t.newScreen("wizard", prompt, rows...))
}
// wizardInvalidInput redraws the draft with a rejection hint: the wizard keeps
// ONE message, so a retry does not stack a hint under a stale card.
func (t *Tgbot) wizardInvalidInput(chatID int64, draft *clientDraft) {
rows := append([][]telego.InlineKeyboardButton{}, t.getCommonClientButtons(draft)...)
rows = append(rows, tu.InlineKeyboardRow(t.btn("tgbot.buttons.use_default", "add_client_default_info")))
t.renderScreen(chatID, t.newScreen("wizard",
t.BuildClientDraftMessage(draft)+"\n\n"+t.I18nBot("tgbot.messages.incorrect_input"), rows...))
}
// screenInviteLink draws the invite link of a client as instructions plus the
// link itself, so an admin can forward it without leaving the screen.
func (t *Tgbot) screenInviteLink(chatID int64, email string) {
record, err := t.clientService.GetRecordByEmail(nil, email)
username := botUsername()
if err != nil || record.SubID == "" || username == "" {
logger.Warning("tgbot: invite link unavailable for", email, err)
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
payload, ok := encodeInvitePayload(record.SubID)
if !ok {
logger.Warning("tgbot: subId of", email, "is too long for a Telegram invite link")
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
link := "https://t.me/" + username + "?start=" + payload
body := t.clientScreenBody(email, t.I18nBot("tgbot.buttons.inviteLink"))
body += "<code>" + html.EscapeString(link) + "</code>\n\n"
body += t.I18nBot("tgbot.messages.inviteHint")
rows := [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))),
t.backRow(),
}
t.renderScreen(chatID, t.newScreen("client", body, rows...))
}
// openOwnClientScreen resolves the client behind a Telegram user and opens the
// asked-for screen; several subscriptions become one button each.
func (t *Tgbot) openOwnClientScreen(chatID, tgUserID int64, action string) {
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
if err != nil {
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
return
}
if len(traffics) == 0 {
t.sendNotice(chatID, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
return
}
if len(traffics) == 1 {
t.openClientAction(chatID, action, traffics[0].Email)
return
}
var buttons []telego.InlineKeyboardButton
for _, tr := range traffics {
buttons = append(buttons, tu.InlineKeyboardButton(tr.Email).
WithCallbackData(t.encodeQuery(action+" "+tr.Email)))
}
cols := 1
if len(buttons) >= 6 {
cols = 2
}
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
rows := append(keyboard.InlineKeyboard, t.backRow())
t.renderScreen(chatID, t.newScreen("links", t.I18nBot("tgbot.commands.pleaseChoose"), rows...))
}
// openClientAction opens one per-client screen by name.
func (t *Tgbot) openClientAction(chatID int64, action, email string) {
switch action {
case "client_sub_links":
t.screenClientLinks(chatID, email)
case "client_individual_links":
t.screenIndividualLinks(chatID, email)
case "client_qr_links":
t.screenClientQR(chatID, email)
}
}
+598
View File
@@ -0,0 +1,598 @@
package tgbot
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
)
// The bot's lists are browsed in inline mode, which must be enabled by hand in
// @BotFather; when off, screens show a hint rather than a silent dead button.
// The launcher carries its own intent as the query text (empty = all inbounds,
// "cl" = every client, "cl <id>" = one inbound's), so nothing is kept server-side.
const (
inlineScopeInbounds = ""
inlineScopeClients = "cl"
)
// scopeQuery is the inline-query text the launcher opens with.
func scopeQuery(kind, data string) string {
if kind == inlineScopeClients && data != "" {
return inlineScopeClients + " " + data
}
if kind == inlineScopeClients {
return inlineScopeClients
}
return ""
}
// inlineSupport caches getMe().supports_inline_queries. A permanent cache would
// freeze a pre-/setinline False forever, so it refreshes once stale.
type inlineSupport struct {
mu sync.Mutex
ok bool
known bool
at time.Time
}
var inlineCapability = &inlineSupport{}
const inlineCapabilityTTL = 10 * time.Minute
// recordInlineCapability stores what getMe() reported.
func recordInlineCapability(supported bool) {
inlineCapability.mu.Lock()
inlineCapability.ok = supported
inlineCapability.known = true
inlineCapability.at = time.Now()
inlineCapability.mu.Unlock()
}
// inlineSupported reports whether the launcher may be shown. A stale value is
// served and refreshed off-path; an unknown empty cache must not warn (post-restart bug).
func (t *Tgbot) inlineSupported() bool {
inlineCapability.mu.Lock()
ok, known, at := inlineCapability.ok, inlineCapability.known, inlineCapability.at
inlineCapability.mu.Unlock()
if known && time.Since(at) <= inlineCapabilityTTL {
return ok
}
if !known {
t.refreshInlineCapability()
inlineCapability.mu.Lock()
defer inlineCapability.mu.Unlock()
return inlineCapability.ok
}
go t.refreshInlineCapability()
return ok
}
func (t *Tgbot) refreshInlineCapability() {
if liveBot() == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
me, err := liveBot().GetMe(ctx)
if err != nil {
logger.Debug("Cannot read the bot capability for inline mode:", err)
return
}
recordInlineCapability(me.SupportsInlineQueries)
}
// launchRow is the one way into a list: a launcher that opens inline mode in
// this chat, or the hint that says why there is none.
func (t *Tgbot) launchRow(query string, labelKey string) []telego.InlineKeyboardButton {
if !t.inlineSupported() {
return tu.InlineKeyboardRow(t.btn("tgbot.buttons.inlineDisabled", "inline_help"))
}
// Telegram prefills and returns this token as the query, so it is both intent
// and filter prefix; not localized because a translated marker could not parse.
btn := telego.InlineKeyboardButton{
Text: t.I18nBot(labelKey),
SwitchInlineQueryCurrentChat: &query,
}
return tu.InlineKeyboardRow(btn)
}
// Capabilities is the bot's own answer to "can you show lists?": inline mode is
// a BotFather switch, so the panel can only warn, never enable it.
type Capabilities struct {
InlineEnabled bool
// GroupPrivacy means the bot CANNOT read every group message, so a /start
// typed in a group never reaches it unless the message mentions the liveBot().
GroupPrivacy bool
Username string
Running bool
}
// Capabilities probes the live bot for what it can do.
func (t *Tgbot) Capabilities() (Capabilities, error) {
caps := Capabilities{Running: t.IsRunning(), Username: botUsername()}
if liveBot() == nil {
return caps, errors.New("bot is not initialised")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
me, err := liveBot().GetMe(ctx)
if err != nil {
return caps, err
}
caps.InlineEnabled = me.SupportsInlineQueries
// can_read_all_group_messages is the inverse of BotFather's Group Privacy.
caps.GroupPrivacy = !me.CanReadAllGroupMessages
caps.Username = me.Username
recordInlineCapability(me.SupportsInlineQueries)
return caps, nil
}
// screenInlineHelp explains a bot that cannot open lists: inline mode is a
// BotFather switch, so the screen tells the admin exactly which one to flip.
func (t *Tgbot) screenInlineHelp(chatID int64) {
rows := [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.inline.recheck", "inline_recheck")),
t.backRow(),
}
t.renderScreen(chatID, t.newScreen("commands",
t.I18nBot("tgbot.inline.offTitle")+"\r\n\r\n"+t.I18nBot("tgbot.inline.offBody"), rows...))
}
// handleInlineQuery answers one query with the items of its scope.
func (t *Tgbot) handleInlineQuery(query *telego.InlineQuery) {
if liveBot() == nil {
return
}
level := t.levelOf(query.From.ID)
if level == levelStranger {
t.answerInline(query.ID, nil, "")
return
}
// Telegram prefills the typed text with the launcher's query, so the intent
// arrives before anything the user adds; a bare query means inbounds.
action, data, search := parseInlineQuery(query.Query)
results := t.inlineResults(action, data, search, level == levelAdmin)
t.answerInline(query.ID, results, query.Offset)
}
// maxInlineResults is the Bot API's hard cap on one answer. Exceeding it gets
// the whole answer rejected, which on a big panel means an empty list forever.
const maxInlineResults = 50
// pageInlineResults returns one page and the cursor for the next. The full list
// is built first because the cap is a property of the transport, not of a list.
func pageInlineResults(results []telego.InlineQueryResult, offset string) (page []telego.InlineQueryResult, next string) {
start, err := strconv.Atoi(offset)
if err != nil || start < 0 {
start = 0
}
if start >= len(results) {
return nil, ""
}
end := start + maxInlineResults
if end < len(results) {
return results[start:end], strconv.Itoa(end)
}
return results[start:], ""
}
// markerResultID keeps a result id inside the 64-byte cap Telegram enforces: one
// long email would otherwise sink the entire answer.
func markerResultID(marker string) string {
if len(marker) <= 63 {
return marker
}
sum := sha256.Sum256([]byte(marker))
return "h:" + hex.EncodeToString(sum[:16])
}
// answerInline answers with page, or with the "nothing here" card: an
// unanswered query leaves Telegram spinning.
func (t *Tgbot) answerInline(queryID string, results []telego.InlineQueryResult, offset string) {
page, next := pageInlineResults(results, offset)
if len(results) == 0 {
hint := t.I18nBot("tgbot.inline.noneTitle")
page = []telego.InlineQueryResult{&telego.InlineQueryResultArticle{
Type: "article",
ID: "none",
Title: hint,
Description: t.I18nBot("tgbot.inline.noneDesc"),
InputMessageContent: &telego.InputTextMessageContent{
MessageText: "none:" + hint,
},
}}
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
err := liveBot().AnswerInlineQuery(ctx, &telego.AnswerInlineQueryParams{
InlineQueryID: queryID,
Results: page,
NextOffset: next,
CacheTime: 0,
IsPersonal: true,
})
if err != nil {
// A stale query id is normal (the user kept typing); never a failure.
logger.Debug("answerInlineQuery failed:", err)
}
}
// inlineResults builds one inline query's items. Inbound lists are admin-only:
// that browser leaked every inbound's remark, protocol, traffic and client count.
func (t *Tgbot) inlineResults(action, data, search string, isAdmin bool) []telego.InlineQueryResult {
if action == inlineScopeClients {
return t.clientResults(search, isAdmin, data)
}
if !isAdmin {
return nil
}
if action == "add" {
return t.inboundResults(search, "add")
}
return t.inboundResults(search, "inb")
}
// parseInlineQuery splits "cl 3 user@x" into its action, inbound and the text to
// filter by. Nothing found means the inbounds list.
func parseInlineQuery(text string) (action, data, search string) {
trimmed := strings.TrimSpace(text)
lower := strings.ToLower(trimmed)
for _, marker := range []string{inlineScopeClients, "add"} {
if !strings.HasPrefix(lower, marker) {
continue
}
rest := strings.TrimSpace(trimmed[len(marker):])
if marker == inlineScopeClients {
// "cl 3 name" — the first token is the inbound when it is a number.
fields := strings.SplitN(rest, " ", 2)
if len(fields) > 0 && fields[0] != "" {
if _, err := strconv.Atoi(fields[0]); err == nil {
if len(fields) == 2 {
return marker, fields[0], fields[1]
}
return marker, fields[0], ""
}
}
return marker, "", rest
}
return marker, "", rest
}
return inlineScopeInbounds, "", trimmed
}
// inboundResults lists inbounds as markers. action is what a chosen item means:
// "inb" opens the inbound, "add" starts the add-client wizard on it.
func (t *Tgbot) inboundResults(search, action string) []telego.InlineQueryResult {
inbounds, err := t.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("Cannot list inbounds for inline mode:", err)
return nil
}
needle := strings.ToLower(strings.TrimSpace(search))
results := make([]telego.InlineQueryResult, 0, len(inbounds))
for _, ib := range inbounds {
remark := ib.Remark
if remark == "" {
remark = "#" + strconv.Itoa(ib.Id)
}
status := "❌"
if ib.Enable {
status = "✅"
}
description := fmt.Sprintf("#%d · %s · %s · 🚦 %s\n👥 %d", ib.Id, ib.Protocol, status,
common.FormatTraffic(ib.Up+ib.Down), len(ib.ClientStats))
if needle != "" && !strings.Contains(strings.ToLower(remark+" "+string(ib.Protocol)+" "+strconv.Itoa(ib.Port)), needle) {
continue
}
marker := action + ":" + strconv.Itoa(ib.Id)
results = append(results, &telego.InlineQueryResultArticle{
Type: "article",
ID: markerResultID(marker),
Title: remark,
Description: description,
InputMessageContent: &telego.InputTextMessageContent{
MessageText: marker,
},
})
}
return results
}
// clientResults lists clients of one inbound, or of all of them when inboundID
// is empty: an admin browses the panel, a client only ever sees its own.
func (t *Tgbot) clientResults(search string, isAdmin bool, inboundID string) []telego.InlineQueryResult {
needle := strings.ToLower(strings.TrimSpace(search))
results := make([]telego.InlineQueryResult, 0, 32)
add := func(email, remark string, used, limit, expiry int64, enabled, online bool) {
if needle != "" && !strings.Contains(strings.ToLower(email+" "+remark), needle) {
return
}
marker := "cl:" + email
results = append(results, &telego.InlineQueryResultArticle{
Type: "article",
ID: markerResultID(marker),
Title: email, // the title is the one thing every client shows
// Telegram renders about three lines here, so the three facts a
// client screen answers first get one line each.
Description: t.clientCardLines(remark, used, limit, expiry, enabled, online),
InputMessageContent: &telego.InputTextMessageContent{
MessageText: marker,
},
})
}
if !isAdmin {
return results
}
inbounds, err := t.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("Cannot list clients for inline mode:", err)
return results
}
seen := map[string]bool{}
for _, ib := range inbounds {
if inboundID != "" && strconv.Itoa(ib.Id) != inboundID {
continue
}
clients, err := t.inboundService.GetClients(ib)
if err != nil {
continue
}
for _, client := range clients {
if seen[client.Email] {
continue
}
seen[client.Email] = true
used := int64(0)
if traffic, err := t.inboundService.GetClientTrafficByEmail(client.Email); err == nil && traffic != nil {
used = traffic.Up + traffic.Down
}
add(client.Email, ib.Remark, used, client.TotalGB, client.ExpiryTime, client.Enable, false)
}
}
return results
}
// clientCardLines is an inline result's description: three lines, because that
// is what Telegram renders under the title.
func (t *Tgbot) clientCardLines(remark string, used, limit, expiry int64, enabled, online bool) string {
head := "❌"
if enabled {
head = "✅"
}
if online {
head += " 🟢"
}
if remark != "" {
head += " · " + remark
}
traffic := common.FormatTraffic(used)
if limit > 0 {
// The percentage is what people actually compare between clients.
traffic += " / " + common.FormatTraffic(limit) +
fmt.Sprintf(" · %d%%", used*100/limit)
} else {
traffic += " / " + t.I18nBot("tgbot.unlimited")
}
expiryText := t.I18nBot("tgbot.unlimited")
switch {
case expiry < 0:
// A negative term is days counted from first use, not a date.
expiryText = t.durationText(-expiry*86400000, true)
case expiry > 0:
expiryText = t.durationText(expiry-time.Now().UnixMilli(), false)
}
return head + "\n🚦 " + traffic + "\n📅 " + expiryText
}
// durationText is a compact, localized span: "12 Minutes", "5 Hours", "30 Days".
// fromFirstUse names the not-yet-started case, whose clock has not begun.
func (t *Tgbot) durationText(ms int64, fromFirstUse bool) string {
if ms <= 0 {
return t.I18nBot("tgbot.inline.expired")
}
if fromFirstUse {
return fmt.Sprintf("%d %s (%s)", ms/86400000, t.I18nBot("tgbot.days"),
t.I18nBot("tgbot.inline.fromFirstUse"))
}
switch {
case ms >= 30*86400000:
return fmt.Sprintf("%d %s", ms/(30*86400000), t.I18nBot("tgbot.months"))
case ms >= 86400000:
return fmt.Sprintf("%d %s", ms/86400000, t.I18nBot("tgbot.days"))
case ms >= 3600000:
return fmt.Sprintf("%d %s", ms/3600000, t.I18nBot("tgbot.hours"))
default:
return fmt.Sprintf("%d %s", ms/60000, t.I18nBot("tgbot.minutes"))
}
}
// handleListMarker catches an item picked in inline mode. The marker arrives as
// the user's own message, so it is deleted first, then the real card is drawn.
func (t *Tgbot) handleListMarker(message *telego.Message) bool {
if message == nil || message.Text == "" {
return false
}
action, value, ok := splitListMarker(message.Text)
if !ok {
return false
}
level := t.levelOf(message.From.ID)
if level == levelStranger {
return true
}
if !t.markerAllowed(action, value, message.From.ID, level == levelAdmin) {
return true
}
// Only a marker that actually opens something is litter worth removing.
t.deleteIncoming(message)
switch action {
case "inb":
id, err := strconv.Atoi(value)
if err != nil {
return true
}
t.screenInboundClients(message.Chat.ID, id)
case "add":
id, err := strconv.Atoi(value)
if err != nil {
return true
}
t.startAddClientForInbound(message.Chat.ID, message.From.ID, id)
case "cl":
if t.clientOwnedByTgUser(message.From.ID, value) || level == levelAdmin {
t.searchClientScreen(message.Chat.ID, value)
}
}
return true
}
// markerAllowed checks the marker against the sender: an admin marker needs the
// admin level, a client marker must name a client that account owns.
func (t *Tgbot) markerAllowed(action, value string, tgUserID int64, isAdmin bool) bool {
switch action {
case "inb", "add":
return isAdmin
case "cl":
if isAdmin {
return true
}
return t.clientOwnedByTgUser(tgUserID, value)
}
return false
}
// messageIsListMarker matches only a picked inline item; a "text contains a
// colon" predicate once swallowed broadcasts and wizard messages (URL, time, comment).
func messageIsListMarker(_ context.Context, update telego.Update) bool {
if update.Message == nil {
return false
}
_, _, ok := splitListMarker(update.Message.Text)
return ok
}
// splitListMarker parses "inb:3", "add:3" or "cl:user@x".
func splitListMarker(text string) (action, value string, ok bool) {
for _, candidate := range []string{"inb:", "add:", "cl:"} {
if rest, found := strings.CutPrefix(text, candidate); found && rest != "" {
return strings.TrimSuffix(candidate, ":"), rest, true
}
}
return "", "", false
}
// screenInboundClients draws one inbound's clients, listed by the inline query.
func (t *Tgbot) screenInboundClients(chatID int64, inboundID int) {
inbound, err := t.inboundService.GetInbound(inboundID)
if err != nil {
t.sendNotice(chatID, t.I18nBot("tgbot.answers.getInboundsFailed"))
return
}
// The clients are browsed in inline mode, never as a callback-button grid:
// a big inbound would otherwise fill the screen with one button per client.
rows := [][]telego.InlineKeyboardButton{
t.launchRow(scopeQuery(inlineScopeClients, strconv.Itoa(inboundID)), "tgbot.buttons.searchClients"),
t.backRow(),
}
t.renderScreen(chatID, t.newScreen("clients", t.inboundSummaryText(inbound), rows...))
}
// screenAllClients is the panel-wide client browser. There is no inbound step:
// the list is every client, and a tap opens the client's own screen.
func (t *Tgbot) screenAllClients(chatID int64) {
body := t.I18nBot("tgbot.messages.clientsCount", "Count=="+strconv.Itoa(t.clientCount()))
body += t.I18nBot("tgbot.messages.inlineHint")
rows := [][]telego.InlineKeyboardButton{
t.launchRow(scopeQuery(inlineScopeClients, ""), "tgbot.buttons.searchClients"),
t.backRow(),
}
t.renderScreen(chatID, t.newScreen("clients", body, rows...))
}
// clientCount is how many clients the panel holds, for the browser's header.
func (t *Tgbot) clientCount() int {
inbounds, err := t.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("Cannot count clients for the browser:", err)
return 0
}
seen := map[string]bool{}
for _, ib := range inbounds {
clients, err := t.inboundService.GetClients(ib)
if err != nil {
continue
}
for _, client := range clients {
seen[client.Email] = true
}
}
return len(seen)
}
// inboundSummaryText is one inbound's header line: remark, protocol, port.
func (t *Tgbot) inboundSummaryText(inbound *model.Inbound) string {
body := t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
body += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
body += t.I18nBot("tgbot.messages.traffic",
"Total=="+common.FormatTraffic(inbound.Up+inbound.Down),
"Upload=="+common.FormatTraffic(inbound.Up),
"Download=="+common.FormatTraffic(inbound.Down))
if inbound.ExpiryTime == 0 {
body += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
} else {
body += t.I18nBot("tgbot.messages.expire",
"Time=="+time.Unix(inbound.ExpiryTime/1000, 0).Format("2006-01-02 15:04:05"))
}
body += t.I18nBot("tgbot.messages.clientsCount", "Count=="+strconv.Itoa(len(inbound.ClientStats)))
return body
}
// startAddClientForInbound opens the add-client wizard on a chosen inbound.
func (t *Tgbot) startAddClientForInbound(chatID, userID int64, inboundID int) {
if _, err := t.inboundService.GetInbound(inboundID); err != nil {
t.sendNotice(chatID, t.I18nBot("tgbot.answers.getInboundsFailed"))
return
}
draft := addClientDrafts.forActor(chatUser{chatID: chatID, userID: userID})
draft.Lock()
defer draft.Unlock()
t.resetDraft(draft)
draft.receiverInboundID = inboundID
draft.receiverInboundIDs = []int{inboundID}
t.addClient(chatID, draft, t.BuildClientDraftMessage(draft))
}
// resetDraft clears the wizard back to its defaults for a fresh client.
func (t *Tgbot) resetDraft(draft *clientDraft) {
draft.email = t.randomLowerAndNum(8)
draft.limitIP = 0
draft.totalGB = 0
draft.expiryTime = 0
draft.enable = true
draft.tgID = ""
draft.subID = t.randomLowerAndNum(16)
draft.comment = ""
draft.reset = 0
}
@@ -0,0 +1,620 @@
package tgbot
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/mymmrac/telego"
)
// inlineCapturingServer records answerInlineQuery results so a test can assert
// on the list Telegram would show, and on the marker an item carries.
func inlineCapturingServer(t *testing.T, supportsInline bool) (*httptest.Server, func() []map[string]any) {
t.Helper()
var mu sync.Mutex
var queries []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
var payload map[string]any
_ = json.Unmarshal(body, &payload)
w.Header().Set("Content-Type", "application/json")
switch method {
case "answerInlineQuery":
mu.Lock()
queries = append(queries, payload)
mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": true})
case "getMe":
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
"id": 42, "is_bot": true, "first_name": "bot", "username": "bot",
"supports_inline_queries": supportsInline,
}})
case "getChat":
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
"id": 42, "type": "private", "photo": map[string]any{"big_file_id": "big"},
}})
case "sendMessage", "sendPhoto", "sendDocument":
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
"message_id": 5, "date": 0, "chat": map[string]any{"id": 1, "type": "private"},
}})
default:
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": true})
}
}))
return srv, func() []map[string]any {
mu.Lock()
defer mu.Unlock()
return append([]map[string]any(nil), queries...)
}
}
// maybeAnswerInline waits briefly for the asynchronous query answer.
func maybeAnswerInline(get func() []map[string]any) []map[string]any {
for range 20 {
if got := get(); len(got) > 0 {
return got
}
time.Sleep(10 * time.Millisecond)
}
return get()
}
func TestInlineQueryListsInboundsWithMarkers(t *testing.T) {
srv, queries := inlineCapturingServer(t, true)
swapTestBot(t, srv.URL)
defer srv.Close()
resetScreenArt()
tb := initReportDB(t)
setTestAdmins(t, 1)
seedReportClients(t, "nl-fast", []string{"a@x"})
tb.handleInlineQuery(&telego.InlineQuery{
ID: "iq1",
From: telego.User{ID: 1},
Query: "",
})
got := maybeAnswerInline(queries)
if len(got) != 1 {
t.Fatalf("answerInlineQuery calls = %d, want 1", len(got))
}
results, _ := got[0]["results"].([]any)
if len(results) == 0 {
t.Fatal("inline results are empty, want the seeded inbound")
}
item, _ := results[0].(map[string]any)
id, _ := item["id"].(string)
if !strings.HasPrefix(id, "inb:") {
t.Errorf("result id = %q, want an inb: marker", id)
}
content, _ := item["input_message_content"].(map[string]any)
if text, _ := content["message_text"].(string); text != id {
t.Errorf("marker text = %q, want the marker itself (%q), never the real content", text, id)
}
}
// /start must draw the menu; it also reaches the route the colon predicate guards, pinning that a
// command is never mistaken for an inline marker.
func TestStartCommandDrawsTheMenu(t *testing.T) {
srv, calls := recordingServer(t, false)
swapTestBot(t, srv.URL)
defer srv.Close()
resetScreenArt()
setTestAdmins(t, 1)
tb := initReportDB(t)
tb.answerCommand(&telego.Message{
MessageID: 1,
Chat: telego.Chat{ID: 500, Type: "private"},
From: &telego.User{ID: 1},
Text: "/start",
}, 500, true)
got := calls()
if len(got) == 0 {
t.Fatal("an admin's /start drew nothing")
}
// The menu is drawn as a photo when art is cached and as text otherwise;
// either way the call has to be there.
drew := false
for _, c := range got {
if c.Method == "sendPhoto" || c.Method == "sendMessage" {
drew = true
}
}
if !drew {
t.Errorf("calls = %v, want the menu message", methods(got))
}
}
// A stranger's /start reports its own id, which is what an admin needs to bind
// it; it must never render the panel menu.
func TestStartCommandForAStrangerRevealsOnlyItsID(t *testing.T) {
srv, calls := recordingServer(t, false)
swapTestBot(t, srv.URL)
defer srv.Close()
resetScreenArt()
setTestAdmins(t, 1)
tb := initReportDB(t)
tb.answerCommand(&telego.Message{
MessageID: 1,
Chat: telego.Chat{ID: 777, Type: "private"},
From: &telego.User{ID: 777777},
Text: "/start",
}, 777, false)
got := calls()
if len(got) == 0 {
t.Fatal("a stranger's /start got no answer at all")
}
// The locale key is what the test environment resolves to; the panel's own
// bundle renders it as the stranger's id, which an admin needs to bind it.
if !strings.Contains(got[0].text(), "askToAddUserId") {
t.Errorf("answer = %q, want the bind-your-id notice", got[0].text())
}
}
// A client list stays scoped to the inbound its screen named, through the inline query, which carries
// the user's id, not the group chat's.
func TestInlineQueryListsClientsOfTheChosenInbound(t *testing.T) {
srv, queries := inlineCapturingServer(t, true)
swapTestBot(t, srv.URL)
defer srv.Close()
resetScreenArt()
inlineCapability.recordForTest(true)
tb := initReportDB(t)
setTestAdmins(t, 1)
seedReportClients(t, "nl-fast", []string{"a@x"})
seedReportClients(t, "de-slow", []string{"b@x"})
inbounds, err := tb.inboundService.GetAllInbounds()
if err != nil || len(inbounds) != 2 {
t.Fatalf("seeded inbounds = %d (err %v), want 2", len(inbounds), err)
}
chosen := inbounds[0]
// The screen is drawn in a group: chat id 500, the admin's own id 1.
tb.screenInboundClients(500, chosen.Id)
// The launcher prefills the query with the browser's intent; that text is
// what Telegram sends back, so the test sends exactly it.
sc, ok := tb.screens().get(500)
if !ok {
t.Fatal("the client screen was not rendered")
}
var queryText string
for _, row := range sc.markup.InlineKeyboard {
for _, btn := range row {
if btn.SwitchInlineQueryCurrentChat != nil {
queryText = *btn.SwitchInlineQueryCurrentChat
}
}
}
if queryText == "" {
t.Fatal("the client screen carries no inline launcher")
}
tb.handleInlineQuery(&telego.InlineQuery{ID: "iq4", From: telego.User{ID: 1}, Query: queryText})
got := maybeAnswerInline(queries)
if len(got) != 1 {
t.Fatalf("answerInlineQuery calls = %d, want 1", len(got))
}
results, _ := got[0]["results"].([]any)
if len(results) != 1 {
t.Fatalf("results = %d, want exactly the chosen inbound's single client", len(results))
}
item, _ := results[0].(map[string]any)
if id, _ := item["id"].(string); !strings.HasPrefix(id, "cl:") {
t.Errorf("result id = %q, want a cl: marker, never an inbound", id)
}
if title, _ := item["title"].(string); title != "a@x" {
t.Errorf("result title = %q, want the client of the chosen inbound", title)
}
}
// The client screen is browsed in inline mode only: a callback-button grid of
// every client is what the screen layer exists to remove.
func TestClientScreenCarriesNoClientButtons(t *testing.T) {
srv, _ := inlineCapturingServer(t, true)
swapTestBot(t, srv.URL)
defer srv.Close()
resetScreenArt()
inlineCapability.recordForTest(true)
tb := initReportDB(t)
setTestAdmins(t, 1)
seedReportClients(t, "nl-fast", []string{"a@x", "b@x"})
inbounds, err := tb.inboundService.GetAllInbounds()
if err != nil || len(inbounds) == 0 {
t.Fatalf("seeded inbounds = %d (err %v), want at least 1", len(inbounds), err)
}
tb.screenInboundClients(500, inbounds[0].Id)
sc, ok := tb.screens().get(500)
if !ok || sc.markup == nil {
t.Fatal("the client screen was not rendered")
}
var buttons []string
for _, row := range sc.markup.InlineKeyboard {
for _, btn := range row {
buttons = append(buttons, btn.CallbackData)
}
}
for _, data := range buttons {
if strings.Contains(data, "client_get_usage") || strings.Contains(data, "@x") {
t.Errorf("the client screen carries the client button %q; clients are inline-only", data)
}
}
if len(buttons) == 0 {
t.Error("the client screen lost its buttons entirely")
}
}
// A query with no list screen behind it must fall back to inbounds: listing
// every client would hand the panel's client names to anyone who can type.
func TestInlineQueryWithoutScopeListsInboundsNotClients(t *testing.T) {
srv, queries := inlineCapturingServer(t, true)
swapTestBot(t, srv.URL)
defer srv.Close()
resetScreenArt()
inlineCapability.recordForTest(true)
tb := initReportDB(t)
setTestAdmins(t, 1)
seedReportClients(t, "nl-fast", []string{"a@x"})
tb.handleInlineQuery(&telego.InlineQuery{ID: "iq5", From: telego.User{ID: 1}, Query: ""})
got := maybeAnswerInline(queries)
results, _ := got[0]["results"].([]any)
for _, raw := range results {
item, _ := raw.(map[string]any)
if id, _ := item["id"].(string); strings.HasPrefix(id, "cl:") {
t.Fatalf("an unscoped query leaked the client %q", id)
}
}
}
// An unknown capability cache must not answer "inline is off": after a restart it warned a bot whose
// inline mode was on until the background refresh landed.
func TestInlineSupportProbesWhenUnknown(t *testing.T) {
srv, _ := inlineCapturingServer(t, true)
swapTestBot(t, srv.URL)
defer srv.Close()
// Fresh process: nothing was recorded yet.
inlineCapability.mu.Lock()
inlineCapability.ok, inlineCapability.known = false, false
inlineCapability.mu.Unlock()
tb := &Tgbot{}
if !tb.inlineSupported() {
t.Error("an unknown cache answered inline-off; the probe must run first")
}
if !inlineCapability.known {
t.Error("the probe did not record the capability")
}
}
// An inline client card is what a user reads before tapping: the email as the
// title, and three lines of facts under it.
func TestInlineClientCardCarriesTrafficAndExpiry(t *testing.T) {
srv, queries := inlineCapturingServer(t, true)
swapTestBot(t, srv.URL)
defer srv.Close()
resetScreenArt()
inlineCapability.recordForTest(true)
tb := initReportDB(t)
setTestAdmins(t, 1)
seedReportClients(t, "nl-fast", []string{"a@x"})
tb.handleInlineQuery(&telego.InlineQuery{ID: "iq7", From: telego.User{ID: 1}, Query: inlineScopeClients})
got := maybeAnswerInline(queries)
results, _ := got[0]["results"].([]any)
if len(results) == 0 {
t.Fatal("the client browser returned nothing")
}
item, _ := results[0].(map[string]any)
if title, _ := item["title"].(string); title != "a@x" {
t.Errorf("title = %q, want the client's email", title)
}
desc, _ := item["description"].(string)
if strings.Count(desc, "\n") != 2 {
t.Errorf("description = %q, want three lines (two newlines)", desc)
}
for _, want := range []string{"🚦", "📅"} {
if !strings.Contains(desc, want) {
t.Errorf("description = %q, want the %s line", desc, want)
}
}
}
// All clients is its own browser: it lists clients straight away and never asks
// for an inbound first.
func TestAllClientsScreenListsClientsWithoutAnInboundStep(t *testing.T) {
srv, queries := inlineCapturingServer(t, true)
swapTestBot(t, srv.URL)
defer srv.Close()
resetScreenArt()
inlineCapability.recordForTest(true)
tb := initReportDB(t)
setTestAdmins(t, 1)
seedReportClients(t, "nl-fast", []string{"a@x", "b@x"})
tb.screenAllClients(500)
sc, ok := tb.screens().get(500)
if !ok || sc.markup == nil {
t.Fatal("All clients drew no screen")
}
var queryText string
for _, row := range sc.markup.InlineKeyboard {
for _, btn := range row {
if btn.SwitchInlineQueryCurrentChat != nil {
queryText = *btn.SwitchInlineQueryCurrentChat
}
}
}
if queryText != inlineScopeClients {
t.Fatalf("launcher query = %q, want the all-clients marker %q", queryText, inlineScopeClients)
}
tb.handleInlineQuery(&telego.InlineQuery{ID: "iq6", From: telego.User{ID: 1}, Query: queryText})
got := maybeAnswerInline(queries)
results, _ := got[0]["results"].([]any)
if len(results) != 2 {
t.Fatalf("results = %d, want both seeded clients", len(results))
}
for _, raw := range results {
item, _ := raw.(map[string]any)
if id, _ := item["id"].(string); !strings.HasPrefix(id, "cl:") {
t.Errorf("result id = %q, want a cl: marker, never an inbound", id)
}
}
}
// The query text is the whole intent, so its parsing is pinned: a server-side
// scope is what kept sending the client step back to the inbound list.
func TestParseInlineQuery(t *testing.T) {
cases := []struct {
text, action, data, search string
}{
{"", inlineScopeInbounds, "", ""},
{"nl", inlineScopeInbounds, "", "nl"},
{inlineScopeClients, inlineScopeClients, "", ""},
{inlineScopeClients + " 3", inlineScopeClients, "3", ""},
{inlineScopeClients + " 3 vas", inlineScopeClients, "3", "vas"},
{inlineScopeClients + " vas", inlineScopeClients, "", "vas"},
{"add", "add", "", ""},
{"add nl", "add", "", "nl"},
}
for _, c := range cases {
action, data, search := parseInlineQuery(c.text)
if action != c.action || data != c.data || search != c.search {
t.Errorf("parseInlineQuery(%q) = (%q, %q, %q), want (%q, %q, %q)",
c.text, action, data, search, c.action, c.data, c.search)
}
}
}
func TestInlineQueryAnswersEmptyResult(t *testing.T) {
srv, queries := inlineCapturingServer(t, true)
swapTestBot(t, srv.URL)
defer srv.Close()
resetScreenArt()
tb := initReportDB(t)
setTestAdmins(t, 1)
tb.handleInlineQuery(&telego.InlineQuery{ID: "iq2", From: telego.User{ID: 1}, Query: "nothing-matches-this"})
got := maybeAnswerInline(queries)
if len(got) != 1 {
t.Fatalf("answerInlineQuery calls = %d, want 1: an empty list must still answer", len(got))
}
if results, _ := got[0]["results"].([]any); len(results) == 0 {
t.Error("results are empty, want the none-found card")
}
}
func TestInlineQueryRefusesAStranger(t *testing.T) {
srv, queries := inlineCapturingServer(t, true)
swapTestBot(t, srv.URL)
defer srv.Close()
resetScreenArt()
tb := initReportDB(t)
setTestAdmins(t, 1)
seedReportClients(t, "secret", []string{"a@x"})
// 777777 owns no client and is not an admin.
tb.handleInlineQuery(&telego.InlineQuery{ID: "iq3", From: telego.User{ID: 777777}})
got := maybeAnswerInline(queries)
if len(got) != 1 {
t.Fatalf("answerInlineQuery calls = %d, want 1", len(got))
}
results, _ := got[0]["results"].([]any)
for _, raw := range results {
item, _ := raw.(map[string]any)
if id, _ := item["id"].(string); strings.HasPrefix(id, "inb:") || strings.HasPrefix(id, "cl:") {
t.Fatalf("a stranger received the results item %q", id)
}
}
}
func TestInlineLauncherOnlyWhenInlineIsOn(t *testing.T) {
srv, _ := inlineCapturingServer(t, true)
swapTestBot(t, srv.URL)
defer srv.Close()
resetScreenArt()
inlineCapability.recordForTest(true)
tb := &Tgbot{}
row := tb.launchRow("inb", "tgbot.buttons.searchInbounds")
if len(row) != 1 || row[0].SwitchInlineQueryCurrentChat == nil {
t.Fatalf("launcher row = %+v, want a switch-inline button", row)
}
inlineCapability.recordForTest(false)
row = tb.launchRow("inb", "tgbot.buttons.searchInbounds")
if len(row) != 1 || row[0].SwitchInlineQueryCurrentChat != nil {
t.Fatalf("row = %+v, want the inline-off hint instead of a dead button", row)
}
if row[0].CallbackData != "inline_help" {
t.Errorf("hint callback = %q, want inline_help", row[0].CallbackData)
}
}
// setTestAdmins registers admins for the duration of a test.
func setTestAdmins(t *testing.T, ids ...int64) {
t.Helper()
orig := adminSnapshot()
tgBotMutex.Lock()
adminIds = ids
tgBotMutex.Unlock()
t.Cleanup(func() {
tgBotMutex.Lock()
adminIds = orig
tgBotMutex.Unlock()
})
}
// recordForTest pins the capability cache without a round trip.
func (s *inlineSupport) recordForTest(supported bool) {
s.mu.Lock()
s.ok = supported
s.known = true
s.at = time.Now()
s.mu.Unlock()
}
func TestListMarkerParsesAndRejectsForeignPrefixes(t *testing.T) {
for _, tt := range []struct {
text string
action string
value string
ok bool
}{
{"inb:3", "inb", "3", true},
{"add:12", "add", "12", true},
{"cl:user@x", "cl", "user@x", true},
{"hello world", "", "", false},
{"inb:", "", "", false},
{"http://x", "", "", false},
} {
action, value, ok := splitListMarker(tt.text)
if ok != tt.ok || action != tt.action || value != tt.value {
t.Errorf("splitListMarker(%q) = (%q, %q, %v), want (%q, %q, %v)",
tt.text, action, value, ok, tt.action, tt.value, tt.ok)
}
}
}
// A picked item is the only thing the marker route may match. The predicate used
// to be "text contains a colon", which ate broadcast text with a URL or a time.
func TestOnlyAPickedItemMatchesTheMarkerRoute(t *testing.T) {
cases := []struct {
text string
want bool
}{
{"inb:3", true},
{"add:12", true},
{"cl:user@x", true},
{"https://example.com/a", false},
{"backup at 03:00", false},
{"Paid: 2026-11", false},
{"note: see panel", false},
{"inb:", false},
{"", false},
}
for _, c := range cases {
update := telego.Update{Message: &telego.Message{Text: c.text, From: &telego.User{ID: 1}}}
if got := messageIsListMarker(context.Background(), update); got != c.want {
t.Errorf("messageIsListMarker(%q) = %v, want %v", c.text, got, c.want)
}
}
}
// Telegram rejects an answer with more than 50 results, so a big panel has to
// page: an un-paged answer is rejected whole and the list looks empty forever.
func TestBigListIsPagedInsteadOfRejected(t *testing.T) {
all := make([]telego.InlineQueryResult, 0, 120)
for i := range 120 {
all = append(all, &telego.InlineQueryResultArticle{
Type: "article", ID: strconv.Itoa(i), Title: strconv.Itoa(i),
InputMessageContent: &telego.InputTextMessageContent{MessageText: "cl:x" + strconv.Itoa(i)},
})
}
first, next := pageInlineResults(all, "")
if len(first) != maxInlineResults {
t.Fatalf("first page = %d results, want %d", len(first), maxInlineResults)
}
if next != "50" {
t.Fatalf("first page cursor = %q, want %q", next, "50")
}
second, next2 := pageInlineResults(all, next)
if len(second) != 50 {
t.Fatalf("second page = %d results, want 50", len(second))
}
if next2 != "100" {
t.Fatalf("second page cursor = %q, want %q", next2, "100")
}
last, next3 := pageInlineResults(all, next2)
if len(last) != 20 {
t.Fatalf("last page = %d results, want 20", len(last))
}
if next3 != "" {
t.Fatalf("last page cursor = %q, want empty", next3)
}
if _, beyond := pageInlineResults(all, "500"); beyond != "" {
t.Fatalf("past-the-end cursor = %q, want empty", beyond)
}
if got, _ := pageInlineResults(all, "not-a-number"); len(got) != maxInlineResults {
t.Fatalf("garbage cursor returned %d results, want a first page", len(got))
}
}
// A short list must not grow a cursor, or Telegram keeps paging an exhausted list.
func TestShortListHasNoCursor(t *testing.T) {
list := []telego.InlineQueryResult{&telego.InlineQueryResultArticle{Type: "article", ID: "a"}}
page, next := pageInlineResults(list, "")
if len(page) != 1 || next != "" {
t.Fatalf("page = %d results, cursor = %q; want 1 and empty", len(page), next)
}
}
// Telegram caps a result id at 64 bytes; one long email would sink the answer.
func TestLongMarkerStaysInsideTheResultIDLimit(t *testing.T) {
long := "cl:" + strings.Repeat("a", 200) + "@example.com"
id := markerResultID(long)
if len(id) > 64 {
t.Fatalf("result id is %d bytes, want at most 64", len(id))
}
if id == markerResultID("cl:"+strings.Repeat("a", 201)+"@example.com") {
t.Fatal("two different markers hashed to the same id")
}
if got := markerResultID("cl:user@x"); got != "cl:user@x" {
t.Fatalf("short marker = %q, want it unchanged", got)
}
}
+146
View File
@@ -0,0 +1,146 @@
package tgbot
import (
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
)
// Bridges for the screens still served by the legacy views. Each one is a
// screen now, so the router's callback cases only decide which view to draw.
// clearScreen drops the tracked screen and the user's own message: the pilot
// tap that closes the reply keyboard must leave nothing behind.
func (t *Tgbot) clearScreen(message telego.Message) {
t.deleteIncoming(&message)
if sc, ok := t.screens().get(message.Chat.ID); ok && sc.msgID != 0 {
t.deleteOwnMessage(message.Chat.ID, sc.msgID)
}
t.screens().clear(message.Chat.ID)
}
// hideMessage deletes the very message whose button was tapped. The id comes
// from the callback, so nothing has to be remembered to make it work.
func (t *Tgbot) hideMessage(query *telego.CallbackQuery) {
if query.Message == nil {
return
}
chat := query.Message.GetChat()
t.deleteOwnMessage(chat.ID, query.Message.GetMessageID())
// A hidden notice must not stay the chat's screen either, and the next event
// of its kind has to start a fresh card rather than edit a deleted one.
if sc, ok := t.screens().get(chat.ID); ok && sc.msgID == query.Message.GetMessageID() {
t.screens().clear(chat.ID)
}
t.dropNoticeFor(chat.ID, query.Message.GetMessageID())
t.answerSilent(query.ID)
}
// turnPage moves the stored screen one body-page back or forward.
func (t *Tgbot) turnPage(query *telego.CallbackQuery, delta int) {
chatID := query.Message.GetChat().ID
sc, ok := t.screens().get(chatID)
if !ok {
t.answerSilent(query.ID)
return
}
t.showPage(chatID, sc.page+delta)
t.answerSilent(query.ID)
}
// resetAllConfirm is the confirmation screen for the panel-wide traffic reset.
func (t *Tgbot) resetAllConfirm() *screen {
// The forward action and the way out must be different rows: sharing one
// row made "Cancel" read as part of the confirmation.
return t.newScreen("main", t.I18nBot("tgbot.messages.AreYouSure"),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.confirmResetTraffic", "reset_all_traffics_c")),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.back", cbHome)),
)
}
// screenInbounds summarises the inbounds; the browsable, searchable list is the
// inline query, so the screen carries only the summary and the way back.
func (t *Tgbot) screenInbounds(chatID, userID int64) {
_ = userID
body := t.getInboundUsages()
body += t.I18nBot("tgbot.messages.inlineHint")
rows := [][]telego.InlineKeyboardButton{
t.launchRow(scopeQuery(inlineScopeInbounds, ""), "tgbot.buttons.searchInbounds"),
t.backRow(),
}
t.renderScreen(chatID, t.newScreen("inbounds", body, rows...))
}
// browseInboundsScreen is the pick-an-inbound step: the list itself is the
// inline browser, so the screen carries the hint and the search launcher.
func (t *Tgbot) browseInboundsScreen(chatID, userID int64) {
_ = userID
rows := [][]telego.InlineKeyboardButton{
t.launchRow(scopeQuery(inlineScopeInbounds, ""), "tgbot.buttons.searchInbounds"),
t.backRow(),
}
t.renderScreen(chatID, t.newScreen("inbounds", t.I18nBot("tgbot.answers.chooseInbound"), rows...))
}
// screenAddClientStart opens the add-client wizard on a fresh draft: filling the
// draft is the first step, and inbounds are picked on "➕ Attach inbound".
func (t *Tgbot) screenAddClientStart(chatID, userID int64) {
actor := chatUser{chatID: chatID, userID: userID}
addClientDrafts.reset(actor)
draft := addClientDrafts.forActor(actor)
draft.Lock()
defer draft.Unlock()
t.resetDraft(draft)
t.addClient(chatID, draft, t.BuildClientDraftMessage(draft))
}
// restartXrayFromScreen restarts Xray and reports the outcome as a toast, so
// the menu screen itself stays put.
func (t *Tgbot) restartXrayFromScreen(query *telego.CallbackQuery) {
if !t.xrayService.IsXrayRunning() {
t.sendCallbackAnswerTgBot(query.ID, t.I18nBot("tgbot.commands.xrayNotRunning"))
return
}
if err := t.xrayService.RestartXray(true); err != nil {
logger.Warning("Xray restart failed:", err)
t.sendCallbackAnswerTgBot(query.ID, t.I18nBot("tgbot.commands.restartFailed", "Error=="+err.Error()))
return
}
t.sendCallbackAnswerTgBot(query.ID, t.I18nBot("tgbot.commands.restartSuccess"))
}
// searchClientScreen renders the client card as a screen; every per-email
// callback that used to post a message lands here.
func (t *Tgbot) searchClientScreen(chatID int64, email string) {
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
if err != nil || traffic == nil {
t.sendNotice(chatID, t.I18nBot("tgbot.noResult"))
return
}
body := t.clientInfoMsg(traffic, true, true, true, true, true, true)
t.renderScreen(chatID, t.newScreen("client", body, t.clientRows(email)...))
}
// clientRows is the client card's keyboard, in one place so every entry point
// draws an identical card.
func (t *Tgbot) clientRows(email string) [][]telego.InlineKeyboardButton {
return [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", t.encodeQuery("client_refresh "+email))),
tu.InlineKeyboardRow(
t.btn("tgbot.buttons.resetTraffic", t.encodeQuery("reset_traffic "+email)),
t.btn("tgbot.buttons.limitTraffic", t.encodeQuery("limit_traffic "+email)),
),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.resetExpire", t.encodeQuery("reset_exp "+email))),
tu.InlineKeyboardRow(
t.btn("tgbot.buttons.ipLog", t.encodeQuery("ip_log "+email)),
t.btn("tgbot.buttons.ipLimit", t.encodeQuery("ip_limit "+email)),
),
tu.InlineKeyboardRow(
t.btn("tgbot.buttons.setTGUser", t.encodeQuery("tg_user "+email)),
t.btn("tgbot.buttons.inviteLink", t.encodeQuery("client_invite_link "+email)),
),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.toggle", t.encodeQuery("toggle_enable "+email))),
t.backRow(),
}
}
+112
View File
@@ -0,0 +1,112 @@
package tgbot
import (
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
)
// Every screen's keyboard is built here, one builder per screen: keyboards built
// inside the router's callback cases could be edited without their screen noticing.
const (
cbHome = "home"
cbActions = "act"
cbHide = "hide"
cbPageNext = "pg:next"
cbPagePrev = "pg:prev"
cbPageNone = "pg:none"
cbServer = "srv"
cbInbounds = "inb"
cbClients = "cli"
cbOnlines = "onl"
cbDeplete = "dep"
cbReport = "rep"
cbBackup = "bkp"
cbBanLogs = "ban"
cbAddClient = "addc"
cbCatServer = "cat:server"
cbCatClients = "cat:clients"
cbCatTraffic = "cat:traffic"
cbBroadcast = "bc"
cbCatMaintenance = "cat:maintenance"
cbResetAll = "rst_all"
cbRestartXry = "xray_restart"
)
// homeRows is the admin's menu: four categories and a refresh, one row per
// subject, so every subject is one tap deeper and nothing is more than two away.
func (t *Tgbot) homeRows() [][]telego.InlineKeyboardButton {
return [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(
t.btn("tgbot.buttons.catServer", cbCatServer),
t.btn("tgbot.buttons.catClients", cbCatClients),
),
tu.InlineKeyboardRow(
t.btn("tgbot.buttons.catTraffic", cbCatTraffic),
t.btn("tgbot.buttons.catMaintenance", cbCatMaintenance),
),
tu.InlineKeyboardRow(
t.btn("tgbot.buttons.refresh", cbHome),
),
}
}
// categoryRows is one menu subject's own rows.
func (t *Tgbot) categoryRows(category string) [][]telego.InlineKeyboardButton {
home := t.backRow()
switch category {
case "server":
return [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.buttons.serverUsage", cbServer)),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.getInbounds", cbInbounds)),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.onlines", cbOnlines)),
home,
}
case "clients":
return [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.buttons.allClients", cbClients)),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.addClient", cbAddClient)),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.broadcast", cbBroadcast)),
home,
}
case "traffic":
return [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.buttons.SortedTrafficUsageReport", cbReport)),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.depleteSoon", cbDeplete)),
home,
}
case "maintenance":
return [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.buttons.dbBackup", cbBackup)),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.getBanLogs", cbBanLogs)),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.restartXray", cbRestartXry)),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.ResetAllTraffics", cbResetAll)),
home,
}
}
return [][]telego.InlineKeyboardButton{home}
}
// screenCategory draws one menu subject. The hint line names the subject, so a
// user who arrived through a notification can see where they are.
func (t *Tgbot) screenCategory(chatID int64, category string) {
body := t.I18nBot("tgbot.messages.category_" + category)
rows := [][]telego.InlineKeyboardButton{}
rows = append(rows, t.categoryRows(category)...)
t.renderScreen(chatID, t.newScreen("main", body, rows...))
}
// btn is where a localized label becomes a callback button; btn2 is btn with a
// caller-supplied label, since a client's email names its subscription best.
func (t *Tgbot) btn2(label, data string) telego.InlineKeyboardButton {
return tu.InlineKeyboardButton(label).WithCallbackData(data)
}
func (t *Tgbot) btn(labelKey, data string) telego.InlineKeyboardButton {
return tu.InlineKeyboardButton(t.I18nBot(labelKey)).WithCallbackData(data)
}
// backRow is the single "up" control a non-root screen carries.
func (t *Tgbot) backRow() []telego.InlineKeyboardButton {
return tu.InlineKeyboardRow(t.btn("tgbot.buttons.back", cbHome))
}
+338
View File
@@ -0,0 +1,338 @@
package tgbot
import (
"context"
"fmt"
"html"
"os"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
)
// Views for the read-only screens. Each one is a pure "read state, return a
// screen" function; the router only decides which view to show.
// screenHome is the admin's landing screen: the numbers you open the bot for,
// then the menu, with the bot's avatar until the panel can host its own artwork.
func (t *Tgbot) screenHome(chatID, userID int64) {
if level := t.levelOf(userID); level != levelAdmin {
t.screenClientHome(chatID, userID)
return
}
body := t.homeSummary()
t.renderScreen(chatID, t.newScreen("main", body, t.homeRows()...))
}
// screenClientHome is a client's own entry point: its usage, then links and QR.
// /start once gave clients the ADMIN menu (panel ops, every client's email).
func (t *Tgbot) screenClientHome(chatID, userID int64) {
traffics, err := t.inboundService.GetClientTrafficTgBot(userID)
if err != nil || len(traffics) == 0 {
t.sendNotice(chatID, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(userID, 10)))
return
}
var body strings.Builder
for _, traffic := range traffics {
body.WriteString(t.clientInfoMsg(traffic, true, true, true, true, true, true))
body.WriteString("\r\n")
}
// One subscription needs no choice (links and QR are right here); several are
// one row each. Either way this is the client's top level, so no back button.
var rows [][]telego.InlineKeyboardButton
if len(traffics) == 1 {
rows = append(rows, tu.InlineKeyboardRow(
t.btn("tgbot.buttons.links", "client_sub_links"),
t.btn("tgbot.buttons.qrCode", "client_qr_links"),
))
} else {
for _, traffic := range traffics {
rows = append(rows, tu.InlineKeyboardRow(
t.btn2(traffic.Email, t.encodeQuery("client_sub_links "+traffic.Email)),
))
}
}
t.renderScreen(chatID, t.rootScreen("main", body.String(), rows...))
}
// homeSummary is the panel overview line used by the menu and the report, so
// the two can never disagree about the host or the version.
func (t *Tgbot) homeSummary() string {
inbounds, err := t.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("Unable to load inbounds for the menu:", err)
}
clients := 0
online := 0
traffic := int64(0)
for _, ib := range inbounds {
clients += len(ib.ClientStats)
traffic += ib.Up + ib.Down
}
if process := xrayProcessOrNil(); process != nil {
online = len(process.GetOnlineClients())
}
body := ""
body += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
body += t.I18nBot("tgbot.messages.version", "Version=="+panelVersion())
body += t.I18nBot("tgbot.messages.inboundsCount", "Count=="+strconv.Itoa(len(inbounds)))
body += t.I18nBot("tgbot.messages.clientsCount", "Count=="+strconv.Itoa(clients))
body += t.I18nBot("tgbot.messages.onlinesCount", "Count=="+strconv.Itoa(online))
body += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(traffic),
"Upload=="+common.FormatTraffic(traffic), "Download=="+common.FormatTraffic(0))
return body
}
// screenServer renders the server-usage screen.
func (t *Tgbot) screenServer(chatID int64) {
body := strings.TrimRight(t.prepareServerUsageInfo(), "\r\n")
rows := [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbServer)),
t.backRow(),
}
t.renderScreen(chatID, t.newScreen("status", body, rows...))
}
// screenOnlines lists who is connected right now; client names are plain text
// (the inline browser takes over the per-client pick next).
func (t *Tgbot) screenOnlines(chatID int64) {
process := xrayProcessOrNil()
if process == nil {
t.renderScreen(chatID, t.newScreen("onlines", t.I18nBot("tgbot.wentWrong"),
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbOnlines)), t.backRow()))
return
}
onlines := process.GetOnlineClients()
body := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+strconv.Itoa(len(onlines)))
for _, online := range onlines {
label := online
if _, inbound, err := t.inboundService.GetClientInboundByEmail(online); err == nil && inbound != nil && inbound.Remark != "" {
label = online + " · " + inbound.Remark
}
body += "• " + html.EscapeString(label) + "\n"
}
body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
rows := [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbOnlines)),
t.backRow(),
}
t.renderScreen(chatID, t.newScreen("onlines", body, rows...))
}
// screenDeplete renders the "running out" screen; the scheduled report and the
// button share this text, so a client is never listed twice with a different story.
func (t *Tgbot) screenDeplete(chatID int64) {
body := t.depleteReport()
rows := [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbDeplete)),
t.backRow(),
}
t.renderScreen(chatID, t.newScreen("deplete", body, rows...))
}
// depleteReport builds the exhausted/disabled summary as plain text.
func (t *Tgbot) depleteReport() string {
trDiff, exDiff := t.depletionThresholds()
now := time.Now().Unix() * 1000
inbounds, err := t.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("Unable to load inbounds for the depletion report:", err)
}
exhaustedInbounds, disabledInbounds := 0, 0
exhaustedClients, disabledClients := 0, 0
seen := map[string]bool{}
var clientLines strings.Builder
for _, inbound := range inbounds {
if !inbound.Enable {
disabledInbounds++
continue
}
if (inbound.ExpiryTime > 0 && inbound.ExpiryTime-now < exDiff) ||
(inbound.Total > 0 && inbound.Total-(inbound.Up+inbound.Down) < trDiff) {
exhaustedInbounds++
}
for _, client := range inbound.ClientStats {
if seen[client.Email] {
continue
}
seen[client.Email] = true
if !client.Enable {
disabledClients++
continue
}
if (client.ExpiryTime > 0 && client.ExpiryTime-now < exDiff) ||
(client.Total > 0 && client.Total-(client.Up+client.Down) < trDiff) {
exhaustedClients++
clientLines.WriteString(t.clientInfoMsg(&client, true, false, false, true, true, false))
clientLines.WriteString("\n")
}
}
}
body := ""
body += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.inbounds"))
body += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(disabledInbounds))
body += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedInbounds))
body += "\n"
body += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients"))
body += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(disabledClients))
body += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedClients))
if clientLines.Len() > 0 {
body += "\n" + html.EscapeString(clientLines.String())
}
body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
return body
}
// depletionThresholds reads the two "running out" windows from the settings.
func (t *Tgbot) depletionThresholds() (traffic, expire int64) {
if v, err := t.settingService.GetTrafficDiff(); err == nil && v > 0 {
traffic = int64(v) * 1073741824
}
if v, err := t.settingService.GetExpireDiff(); err == nil && v > 0 {
expire = int64(v) * 86400000
}
return traffic, expire
}
// screenReport renders the sorted traffic-usage report. The screen pages it,
// so a large panel gets one message with page controls, not a message flood.
func (t *Tgbot) screenReport(chatID int64) {
emails, err := t.inboundService.GetAllEmails()
if err != nil {
t.renderScreen(chatID, t.newScreen("reports", t.I18nBot("tgbot.answers.errorOperation"), t.backRow()))
return
}
validEmails, missingEmails, err := t.inboundService.FilterAndSortClientEmails(emails)
if err != nil {
t.renderScreen(chatID, t.newScreen("reports", t.I18nBot("tgbot.answers.errorOperation"), t.backRow()))
return
}
var report strings.Builder
for _, email := range validEmails {
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
if err != nil || traffic == nil {
fmt.Fprintf(&report, "📧 %s\n%s\n", html.EscapeString(email), t.I18nBot("tgbot.noResult"))
continue
}
report.WriteString(t.clientInfoMsg(traffic, false, false, false, false, true, false))
report.WriteString("\n")
}
for _, email := range missingEmails {
fmt.Fprintf(&report, "📧 %s\n%s\n", html.EscapeString(email), t.I18nBot("tgbot.noResult"))
}
rows := [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbReport)),
t.backRow(),
}
t.renderScreen(chatID, t.newScreen("reports", html.EscapeString(report.String()), rows...))
}
// screenCommands explains the classic commands; kept because muscle memory is
// real, but the menu no longer advertises them.
func (t *Tgbot) screenCommands(chatID int64, isAdmin bool) {
key := "tgbot.commands.helpClientCommands"
if isAdmin {
key = "tgbot.commands.helpAdminCommands"
}
t.renderScreen(chatID, t.newScreen("commands", t.I18nBot(key), t.backRow()))
}
// sendBackupScreen delivers the backup and summary as ONE message (file plus
// caption); a summary too long for a caption is sent after the file.
func (t *Tgbot) sendBackupScreen(chatID int64) {
dbData, err := t.serverService.GetDb()
if err != nil {
logger.Error("Error in getting db backup:", err)
}
summary := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
summary += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
if dbData == nil {
t.sendNotice(chatID, summary)
return
}
// The summary and the depletion list share the file's caption: one message
// carries the backup and what it is for.
body := summary + "\n\n" + t.depleteReport()
caption := trimCaption(body, botCaptionLimit)
if t.sendDocumentWithCaption(chatID, dbData, t.serverService.BackupFilename(""), caption) {
// The generated config travels with the DB on both backup paths: an
// operator restoring from the file would otherwise lose it silently.
if config, err := os.ReadFile(xray.GetConfigPath()); err == nil {
t.sendDocumentWithCaption(chatID, config, "config.json", "")
}
if rest := strings.TrimSpace(strings.TrimPrefix(body, caption)); rest != "" {
// Rare: the summary outgrew a caption. The file already landed.
t.sendNotice(chatID, rest)
}
return
}
// The upload failed: the summary is still worth sending.
t.sendNotice(chatID, summary)
}
// screenBanLogs sends the ban log file as a notice, never as a screen.
func (t *Tgbot) screenBanLogs(chatID int64) {
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
output += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
t.sendNotice(chatID, output)
t.sendBanLogsFile(chatID)
}
// sendNotice posts a plain bot message that is explicitly not a screen: it
// carries the hide button, and it never becomes the chat's tracked screen.
func (t *Tgbot) sendNotice(chatID int64, text string) {
t.sendNoticeReturningID(chatID, text)
}
// sendNoticeReturningID posts one dismissable message and hands back its id, so
// the caller can replace it later instead of leaving copies behind.
func (t *Tgbot) sendNoticeReturningID(chatID int64, text string) int {
if liveBot() == nil || text == "" {
return 0
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
sent, err := liveBot().SendMessage(ctx, &telego.SendMessageParams{
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: text, ParseMode: "HTML", ReplyMarkup: t.hideButton(),
})
if err != nil {
logger.Warning("Failed to send a notice:", err)
return 0
}
return sent.MessageID
}
// sendBanLogsFile delivers the banned-IP log as a notice with a hide button.
func (t *Tgbot) sendBanLogsFile(chatID int64) {
file, err := os.Open(xray.GetIPLimitBannedPrevLogPath())
if err != nil {
t.sendNotice(chatID, t.I18nBot("tgbot.noResult"))
return
}
defer file.Close()
if liveBot() == nil {
return
}
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.File(file),
ReplyMarkup: t.hideButton(),
})
if err != nil {
logger.Warning("Failed to upload the ban log:", err)
}
}
+179
View File
@@ -0,0 +1,179 @@
package tgbot
import (
"bytes"
"context"
"image"
"image/color"
"image/draw"
"image/png"
"sync"
"sync/atomic"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
)
// Screen art: the bot's avatar serves every screen's picture, and a panel without
// one gets a generated black tile, so a screen never fails for want of a picture.
var (
screenArtMu sync.RWMutex
screenArtIDs = map[string]string{}
// artOverrides is artwork the panel set for one screen kind. It outranks
// the bot's avatar, so adopting an avatar never silently replaces it.
artOverrides = map[string]string{}
// artFromAvatar records whether the cached ids came from the bot's avatar: a
// tile cached from an upload must never win over an avatar set afterwards.
artFromAvatar bool
artProbedAt time.Time
// artProbeRunning keeps a burst of renders from starting a burst of probes.
artProbeRunning atomic.Bool
blackTileOnce sync.Once
blackTileBytes []byte
)
// Probes are cheap but not free: a known avatar is trusted for a while, a missing
// one retried sooner, so an uploaded picture shows without a restart.
const (
artAvatarTTL = 10 * time.Minute
artRetryTTL = time.Minute
)
// resolveScreenArt caches the bot avatar's file_id for every screen kind.
// Telegram serves one photo per bot, so all kinds share it.
func (t *Tgbot) resolveScreenArt() {
// One read: the panel can swap the bot under us (login/logout), and reading
// the global per call is a nil-pointer crash in this background goroutine.
b := liveBot()
if b == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
me, err := b.GetMe(ctx)
if err != nil {
logger.Warning("Failed to read the bot identity for screen art:", err)
return
}
// Use getUserProfilePhotos, not getChat: getChat's ChatPhoto file_id is
// refused in sendPhoto ("can't use file of type ChatPhoto as Photo").
photos, err := b.GetUserProfilePhotos(ctx, &telego.GetUserProfilePhotosParams{
UserID: me.ID, Limit: 1,
})
if err != nil {
logger.Debug("Failed to read the bot's profile photos for screen art:", err)
return
}
screenArtMu.Lock()
defer screenArtMu.Unlock()
artProbedAt = time.Now()
if len(photos.Photos) == 0 || len(photos.Photos[0]) == 0 {
// Remember the miss so the next render does not probe again, but keep
// any tile already cached: an avatar-less bot still shows a picture.
artFromAvatar = false
logger.Debug("Bot has no avatar; screens will use the black fallback")
return
}
// The last entry is the largest size Telegram keeps.
sizes := photos.Photos[0]
fileID := sizes[len(sizes)-1].FileID
screenArtIDs = map[string]string{}
for _, kind := range screenKinds {
screenArtIDs[kind] = fileID
}
artFromAvatar = true
}
// ensureScreenArt refreshes stale cached art off the render path, at most one
// probe in flight, so a just-uploaded picture shows without a restart.
func (t *Tgbot) ensureScreenArt() {
screenArtMu.RLock()
fromAvatar, at := artFromAvatar, artProbedAt
screenArtMu.RUnlock()
ttl := artRetryTTL
if fromAvatar {
ttl = artAvatarTTL
}
if time.Since(at) <= ttl {
return
}
if !artProbeRunning.CompareAndSwap(false, true) {
return
}
go func() {
defer artProbeRunning.Store(false)
t.resolveScreenArt()
}()
}
// artFileID returns the cached file_id for a screen kind. A fallback tile cached
// for one kind must not answer for another, so the per-kind entry is authoritative.
func artFileID(kind string) (string, bool) {
screenArtMu.RLock()
defer screenArtMu.RUnlock()
if id, ok := artOverrides[kind]; ok && id != "" {
return id, true
}
id, ok := screenArtIDs[kind]
return id, ok && id != ""
}
// artUpload is the last-resort picture: the generated black tile.
func artUpload(kind string) (telego.InputFile, bool) {
data := blackTile()
return tu.FileFromBytes(data, "screen.png"), data != nil
}
// blackTile builds the 512x512 black tile once.
func blackTile() []byte {
blackTileOnce.Do(func() {
img := image.NewRGBA(image.Rect(0, 0, 512, 512))
draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{R: 13, G: 15, B: 20, A: 255}}, image.Point{}, draw.Src)
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
logger.Warning("Failed to encode the fallback screen art:", err)
return
}
blackTileBytes = buf.Bytes()
})
return blackTileBytes
}
// cacheArtFileID remembers the file_id Telegram returned for an uploaded tile,
// so that screen edits in place. It does NOT mark the art as the avatar.
func cacheArtFileID(kind, fileID string) {
if kind == "" || fileID == "" {
return
}
screenArtMu.Lock()
if !artFromAvatar {
screenArtIDs[kind] = fileID
}
screenArtMu.Unlock()
}
// resetScreenArt drops the cached file_ids; tests use it between mock servers.
func resetScreenArt() {
screenArtMu.Lock()
screenArtIDs = map[string]string{}
artOverrides = map[string]string{}
artFromAvatar = false
artProbedAt = time.Time{}
screenArtMu.Unlock()
}
// screenKinds is the catalogue of screen pictures: every kind resolves to the
// same avatar, but the names keep each screen's intent visible in the code.
var screenKinds = []string{
"main", "status", "inbounds", "clients", "client", "links", "qr",
"wizard", "reports", "backup", "onlines", "deplete", "commands", "broadcast",
}
File diff suppressed because it is too large Load Diff
+18 -11
View File
@@ -31,6 +31,9 @@ import (
var (
bot *telego.Bot
// botMu guards bot: EnsureBot may replace it while a background goroutine
// (the screen artwork warm-up) is still using the previous liveBot().
botMu sync.RWMutex
// botCancel stores the function to cancel the context, stopping Long Polling gracefully.
botCancel context.CancelFunc
@@ -66,6 +69,13 @@ var (
}
)
// liveBot is the current connection, or nil before login and after logout.
func liveBot() *telego.Bot {
botMu.RLock()
defer botMu.RUnlock()
return bot
}
// clientDraft is one chat's add-client wizard state. Per-protocol secrets are
// filled per-inbound on submit, so only the universal fields live here.
type clientDraft struct {
@@ -346,13 +356,16 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
}
// Create new Telegram bot instance
botMu.Lock()
bot, err = t.NewBot(tgBotToken, tgBotProxy, tgBotAPIServer)
botMu.Unlock()
if err != nil {
logger.Error("Failed to initialize Telegram bot API:", err)
return err
}
t.trySetBotCommands(bot)
t.resolveScreenArt()
t.trySetBotCommands(liveBot())
// Start receiving Telegram bot messages
tgBotMutex.Lock()
@@ -373,17 +386,11 @@ func (t *Tgbot) trySetBotCommands(bot *telego.Bot) {
}
}()
// Only /start is advertised: the rest of the actions live on buttons, and
// the old list merely duplicated the menu. The commands still work.
err := bot.SetMyCommands(context.Background(), &telego.SetMyCommandsParams{
Commands: []telego.BotCommand{
{Command: "start", Description: t.I18nBot("tgbot.commands.startDesc")},
{Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")},
{Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")},
{Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")},
{Command: "usage", Description: t.I18nBot("tgbot.commands.usageDesc")},
{Command: "inbound", Description: t.I18nBot("tgbot.commands.inboundDesc")},
{Command: "restart", Description: t.I18nBot("tgbot.commands.restartDesc")},
{Command: "clearall", Description: t.I18nBot("tgbot.commands.clearallDesc")},
{Command: "broadcast", Description: t.I18nBot("tgbot.commands.broadcastDesc")},
},
})
if err != nil {
@@ -551,7 +558,7 @@ func inAdminTopic(document *telego.SendDocumentParams) *telego.SendDocumentParam
return document
}
// SetHostname sets the hostname for the bot.
// SetHostname sets the hostname for the liveBot().
func (t *Tgbot) SetHostname() {
host, err := os.Hostname()
if err != nil {
@@ -594,7 +601,7 @@ func StopBot() {
}
if cancel != nil {
logger.Info("Sending cancellation signal to Telegram bot...")
logger.Info("Sending cancellation signal to Telegram liveBot()...")
// Cancels the context passed to UpdatesViaLongPolling; this closes updates channel
// and lets botHandler.Start() exit cleanly.
cancel()
@@ -1,64 +0,0 @@
package tgbot
import (
"fmt"
"path/filepath"
"slices"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mymmrac/telego"
"github.com/nicksnyder/go-i18n/v2/i18n"
)
func seedPickerInbounds(t *testing.T, protocols ...model.Protocol) {
t.Helper()
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
for i, protocol := range protocols {
port := 20000 + i
ib := &model.Inbound{Remark: string(protocol), Enable: true, Port: port, Protocol: protocol, Tag: fmt.Sprintf("inbound-%d", port), Settings: `{}`}
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("seed %s inbound: %v", protocol, err)
}
}
}
func pickerLabels(keyboard *telego.InlineKeyboardMarkup) []string {
var labels []string
for _, row := range keyboard.InlineKeyboard {
for _, button := range row {
labels = append(labels, button.Text)
}
}
slices.Sort(labels)
return labels
}
// WireGuard and AmneziaWG clients get a generated keypair and address on Create,
// so the wizard offers them; Mixed authenticates per inbound and has no clients.
func TestAddClientPickerOffersWireGuardAndAmneziaWG(t *testing.T) {
seedPickerInbounds(t, model.VLESS, model.WireGuard, model.AmneziaWG, model.Mixed)
keyboard, err := (&Tgbot{}).getInboundsAddClient()
if err != nil {
t.Fatalf("getInboundsAddClient: %v", err)
}
want := []string{"amneziawg - ✅", "vless - ✅", "wireguard - ✅"}
if got := pickerLabels(keyboard); !slices.Equal(got, want) {
t.Fatalf("picker buttons = %q, want %q", got, want)
}
}
// An empty keyboard sent the admin a "choose inbound" prompt with nothing to tap.
func TestAddClientPickerFailsWhenNoInboundTakesClients(t *testing.T) {
draftLocalizer(t, &i18n.Message{ID: "tgbot.answers.getInboundsFailed", Other: "Failed to get inbounds."})
seedPickerInbounds(t, model.Mixed, model.HTTP, model.Tunnel)
keyboard, err := (&Tgbot{}).getInboundsAddClient()
if err == nil || err.Error() != "Failed to get inbounds." {
t.Fatalf("getInboundsAddClient = (%v, %v), want the getInboundsFailed error", keyboard, err)
}
}
@@ -0,0 +1,138 @@
package tgbot
import (
"encoding/json"
"slices"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mymmrac/telego"
)
// seedPickerInbounds inserts one enabled inbound per protocol.
func seedPickerInbounds(t *testing.T, protocols ...model.Protocol) {
t.Helper()
for _, protocol := range protocols {
tag := "pick-" + string(protocol)
if err := database.GetDB().Create(&model.Inbound{
UserId: 1, Tag: tag, Remark: string(protocol), Port: 8443,
Protocol: protocol, Enable: true,
}).Error; err != nil {
t.Fatalf("seed %s inbound: %v", protocol, err)
}
}
}
// attachPickerRows builds the attach picker for a draft and flattens it.
func attachPickerRows(t *testing.T, tb *Tgbot, draft *clientDraft) []telego.InlineKeyboardButton {
t.Helper()
picker, err := tb.getInboundsAttachPicker(draft)
if err != nil {
t.Fatalf("getInboundsAttachPicker: %v", err)
}
var rows []telego.InlineKeyboardButton
for _, row := range picker.InlineKeyboard {
rows = append(rows, row...)
}
return rows
}
// WireGuard and AmneziaWG clients get a generated keypair and address on Create,
// so the attach picker offers them; Mixed authenticates per inbound and has none.
func TestAttachPickerOffersWireGuardAndAmneziaWG(t *testing.T) {
tb, _ := newScreenTgbot(t, false)
initReportDB(t)
seedPickerInbounds(t, model.VLESS, model.WireGuard, model.AmneziaWG, model.Mixed)
draft := &clientDraft{}
// The picker marks selection, so its labels carry the checkbox and protocol.
rows := attachPickerRows(t, tb, draft)
var labels []string
for _, row := range rows {
labels = append(labels, row.Text)
}
slices.Sort(labels)
for _, want := range []string{"amneziawg", "vless", "wireguard"} {
found := false
for _, label := range labels {
if strings.Contains(label, want) {
found = true
}
}
if !found {
t.Errorf("the attach picker does not offer %s: %q", want, labels)
}
}
for _, label := range labels {
if strings.Contains(label, "mixed") {
t.Errorf("the attach picker offers Mixed, which has no clients: %q", label)
}
}
}
// The wizard starts on a draft, not an inbound picker: the client is created first and attached after,
// the bug that made "Add client" ask for an inbound and then offer existing clients.
func TestAddClientStartsOnADraftWithNoInboundChosen(t *testing.T) {
localizeWithRealBundle(t)
tb, _ := newScreenTgbot(t, false)
tb.screenAddClientStart(777, 1)
sc, ok := tb.screens().get(777)
if !ok {
t.Fatal("the add-client flow drew no screen")
}
// The draft card is shown, and nothing is attached yet.
if !strings.Contains(sc.pages[0], "newclient") && !strings.Contains(sc.pages[0], "Email") {
t.Errorf("the first screen is not the draft card: %s", sc.pages[0])
}
if strings.Contains(sc.pages[0], "pickInbound") || strings.Contains(sc.pages[0], "Pick the inbound") {
t.Errorf("the flow still opens on an inbound picker: %s", sc.pages[0])
}
markup, _ := json.Marshal(sc.markup)
if !strings.Contains(string(markup), "add_client_attach_more") {
t.Errorf("the draft offers no way to attach an inbound: %s", markup)
}
if strings.Contains(string(markup), "add_client_ch_default_email") == false {
t.Errorf("the draft offers no way to set the client's fields: %s", markup)
}
}
// Inbounds are chosen from the draft's own button while filling the card, and submitting without one is
// refused instead of creating a client attached to nothing.
func TestDraftCanBeFilledBeforeAnyInboundIsAttached(t *testing.T) {
localizeWithRealBundle(t)
tb, _ := newScreenTgbot(t, false)
draft := &clientDraft{email: "draft-only"}
rows := tb.getCommonClientButtons(draft)
markup, _ := json.Marshal(rows)
for _, want := range []string{
"add_client_ch_default_email", "add_client_ch_default_comment",
"add_client_ch_default_traffic", "add_client_ch_default_exp",
"add_client_ch_default_ip_limit", "add_client_ch_default_tg_id",
"add_client_attach_more", "add_client_submit_enable", "add_client_cancel",
} {
if !strings.Contains(string(markup), want) {
t.Errorf("the draft card lacks %s", want)
}
}
// The attach button says how many are attached, and it is localized.
for _, row := range rows {
for _, button := range row {
if button.CallbackData != "add_client_attach_more" {
continue
}
if strings.TrimSpace(button.Text) == "" || strings.HasPrefix(button.Text, "tgbot.") {
t.Errorf("the attach button has no localized label: %q", button.Text)
}
}
}
if _, err := tb.SubmitAddClient(draft); err == nil {
t.Error("submitting with no inbound attached created a client anyway")
}
}
+167 -30
View File
@@ -33,7 +33,7 @@ const (
)
// broadcastDraft references the admin's original message: copyMessage relays
// any message type 1:1 on behalf of the bot. An album lists its messages.
// any message type 1:1 on behalf of the liveBot(). An album lists its messages.
type broadcastDraft struct {
FromChatID int64
MessageIDs []int
@@ -53,6 +53,7 @@ type broadcastResult struct {
// broadcastRunner tracks the single in-flight broadcast: where to report
// progress and whether the admin asked to stop it.
type broadcastRunner struct {
actor chatUser
chatID int64
messageID int
cancel atomic.Bool
@@ -77,15 +78,21 @@ func (r *broadcastRunner) getResult() broadcastResult {
// album group still arriving, and the token binding the preview to its card.
type broadcastCompose struct {
messageIDs []int
groupID string
token string
timer *time.Timer
// inputMessageIDs are the admin's own messages: the SOURCE of the copy, never
// seen by recipients, so they are removed once the broadcast is confirmed/dropped.
inputMessageIDs []int
groupID string
token string
timer *time.Timer
}
var (
broadcastMu sync.Mutex
broadcastComposes = make(map[chatUser]*broadcastCompose)
broadcastActive *broadcastRunner
// broadcastPrompts is the "send me the message" card per admin, remembered
// only so that leaving the flow takes it out of the chat.
broadcastPrompts = make(map[chatUser]int)
broadcastActive *broadcastRunner
)
// broadcastAlbumDebounce waits out Telegram's stream of one media group: an
@@ -111,6 +118,7 @@ func broadcastResetAll() {
broadcastActive.cancel.Store(true)
broadcastActive = nil
}
broadcastPrompts = make(map[chatUser]int)
}
func broadcastDropCompose(actor chatUser) {
@@ -122,6 +130,18 @@ func broadcastDropCompose(actor chatUser) {
delete(broadcastComposes, actor)
}
// broadcastComposeInputs returns the admin's own composing messages for the
// pending draft, empty while an album is still being collected.
func broadcastComposeInputs(actor chatUser) []int {
broadcastMu.Lock()
defer broadcastMu.Unlock()
c := broadcastComposes[actor]
if c == nil {
return nil
}
return append([]int(nil), c.inputMessageIDs...)
}
// broadcastPendingDraft reports the ids awaiting an admin's confirmation;
// ok is false while an album is still being collected.
func broadcastPendingDraft(actor chatUser) ([]int, string, bool) {
@@ -149,13 +169,13 @@ func broadcastTakePending(actor chatUser, token string) ([]int, bool) {
// broadcastRegisterRunner claims the single broadcast slot; nil means one is
// already running.
func broadcastRegisterRunner(chatID int64) *broadcastRunner {
func broadcastRegisterRunner(actor chatUser) *broadcastRunner {
broadcastMu.Lock()
defer broadcastMu.Unlock()
if broadcastActive != nil {
return nil
}
broadcastActive = &broadcastRunner{chatID: chatID}
broadcastActive = &broadcastRunner{actor: actor, chatID: actor.chatID}
return broadcastActive
}
@@ -175,7 +195,8 @@ func broadcastCurrentRunner() *broadcastRunner {
return broadcastActive
}
// broadcastSender delivers one draft to one chat; swapped out in tests.
// broadcastSender delivers one draft to one chat and reports the ids it made;
// swapped out in tests.
var broadcastSender = deliverBroadcastCopy
// broadcastPause stands in for time.Sleep so tests don't wait real seconds.
@@ -190,14 +211,48 @@ func (t *Tgbot) startBroadcast(actor chatUser) {
return
}
broadcastDropCompose(actor)
// A second prompt supersedes the first: twin "send me the message" cards in
// one chat is the sort of litter this flow is supposed to avoid.
t.clearBroadcastPrompt(actor)
userStateMgr.set(actor, broadcastAwaitingText)
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastAskText"), t.broadcastCancelKeyboard())
id := t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastAskText"), t.broadcastCancelKeyboard())
broadcastSetPrompt(actor, id)
}
// broadcastSetPrompt remembers the "send me the message" card, so leaving the
// flow cannot strand it in the chat.
func broadcastSetPrompt(actor chatUser, messageID int) {
if messageID == 0 {
return
}
broadcastMu.Lock()
broadcastPrompts[actor] = messageID
broadcastMu.Unlock()
}
// broadcastTakePrompt returns the pending prompt and forgets it.
func broadcastTakePrompt(actor chatUser) int {
broadcastMu.Lock()
defer broadcastMu.Unlock()
id := broadcastPrompts[actor]
delete(broadcastPrompts, actor)
return id
}
// clearBroadcastPrompt removes the prompt from the chat, if one is pending.
func (t *Tgbot) clearBroadcastPrompt(actor chatUser) {
if id := broadcastTakePrompt(actor); id != 0 {
t.deleteScreenMessage(actor.chatID, id)
}
}
// handleBroadcastInput references the message the admin sent and shows the
// confirmation preview. The router hands over only the admin /broadcast awaits.
func (t *Tgbot) handleBroadcastInput(message *telego.Message, actor chatUser) {
logger.Debugf("broadcast: chat %d input (message_id=%d group=%q)", actor.chatID, message.MessageID, message.MediaGroupID)
// The prompt has been answered: it is the admin's scratch space, not the
// broadcast, so it does not stay behind the preview card.
t.clearBroadcastPrompt(actor)
if message.MediaGroupID == "" {
broadcastDropCompose(actor)
t.acceptBroadcastDraft(actor, []int{message.MessageID})
@@ -210,17 +265,23 @@ func (t *Tgbot) handleBroadcastInput(message *telego.Message, actor chatUser) {
// exactly what recipients will get — and shows the confirmation preview.
func (t *Tgbot) acceptBroadcastDraft(actor chatUser, ids []int) {
chatId := actor.chatID
if err := broadcastSender(chatId, broadcastDraft{FromChatID: chatId, MessageIDs: ids}); err != nil {
previewIDs, err := broadcastSender(chatId, broadcastDraft{FromChatID: chatId, MessageIDs: ids})
if err != nil {
broadcastDropCompose(actor)
userStateMgr.clear(actor)
logger.Warningf("broadcast: chat %d message %v cannot be copied: %v", chatId, ids, err)
t.clearBroadcastDraft(actor, ids, nil)
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastNotCopyable"))
return
}
recipients := t.collectBroadcastRecipients()
token := t.randomLowerAndNum(12)
broadcastMu.Lock()
broadcastComposes[actor] = &broadcastCompose{messageIDs: ids, token: token}
broadcastComposes[actor] = &broadcastCompose{
messageIDs: ids,
inputMessageIDs: ids,
token: token,
}
broadcastMu.Unlock()
userStateMgr.clear(actor)
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
@@ -228,6 +289,30 @@ func (t *Tgbot) acceptBroadcastDraft(actor chatUser, ids []int) {
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("broadcast_cancel"),
))
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastPreview", "Count=="+strconv.Itoa(len(recipients))), keyboard)
// The preview is the copy recipients receive. It has served its purpose once
// the admin has looked at it; leaving it behind produced duplicate copies.
for _, id := range previewIDs {
t.deleteOwnMessage(chatId, id)
}
}
// clearBroadcastDraft removes the admin's own composing messages — the input the
// broadcast copies FROM and, if made, its preview — none of which is the broadcast.
func (t *Tgbot) clearBroadcastDraft(actor chatUser, inputIDs, previewIDs []int) {
for _, id := range inputIDs {
t.deleteIncomingID(actor.chatID, id)
}
for _, id := range previewIDs {
t.deleteOwnMessage(actor.chatID, id)
}
}
// recallBroadcast undoes a cancelled broadcast by removing copies from every
// recipient. Best-effort: Telegram refuses deleteMessage older than 48 hours.
func (t *Tgbot) recallBroadcast(chatID int64, ids []int) {
for _, id := range ids {
t.deleteOwnMessage(chatID, id)
}
}
// bufferBroadcastMedia appends an album item; the debounce timer fires the
@@ -268,6 +353,8 @@ func (t *Tgbot) finalizeBroadcastAlbum(actor chatUser, groupID string) {
t.acceptBroadcastDraft(actor, ids)
}
// The broadcast flow stays message-scoped, not screen-scoped: in a shared group
// a per-chat screen would make one admin's preview overwrite another's confirm token.
func (t *Tgbot) broadcastCancelKeyboard() *telego.InlineKeyboardMarkup {
return tu.InlineKeyboard(tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("broadcast_cancel"),
@@ -278,7 +365,7 @@ func (t *Tgbot) broadcastCancelKeyboard() *telego.InlineKeyboardMarkup {
// runner slot, replaces the preview with a progress card, and starts delivery.
func (t *Tgbot) confirmBroadcast(actor chatUser, token string, messageID int, queryID string) {
chatId := actor.chatID
runner := broadcastRegisterRunner(chatId)
runner := broadcastRegisterRunner(actor)
if runner == nil {
t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.messages.broadcastAlreadyRunning"))
return
@@ -317,13 +404,15 @@ func (t *Tgbot) runBroadcast(runner *broadcastRunner, draft broadcastDraft, reci
sent, failed, unreachable := 0, 0, 0
lastProgress := time.Now()
canceled := false
// What each recipient actually received, so a cancel can take it back.
delivered := map[int64][]int{}
for i, chatID := range recipients {
if aborted() {
canceled = runner.cancel.Load()
break
}
err := broadcastDeliverOne(chatID, draft, aborted)
ids, err := broadcastDeliverOne(chatID, draft, aborted)
if errors.Is(err, errBroadcastAborted) {
canceled = runner.cancel.Load()
break
@@ -331,6 +420,7 @@ func (t *Tgbot) runBroadcast(runner *broadcastRunner, draft broadcastDraft, reci
switch {
case err == nil:
sent++
delivered[chatID] = ids
case broadcastChatUnreachable(err):
// 403 means the chat never started the bot or blocked it; a long
// recipient list would turn these into log spam at warning level.
@@ -359,10 +449,20 @@ func (t *Tgbot) runBroadcast(runner *broadcastRunner, draft broadcastDraft, reci
Canceled: canceled,
Elapsed: time.Since(start).Round(time.Second),
}
// A cancelled broadcast that says "this is what went out" and leaves it in
// every chat is a lie: the copies are withdrawn before that summary is drawn.
if canceled {
for chatID, ids := range delivered {
t.recallBroadcast(chatID, ids)
}
}
summary := t.broadcastSummaryText(result)
if !t.finalizeBroadcastCard(runner, summary) {
t.SendMsgToTgbot(runner.chatID, summary)
}
// The card is now the only message the run leaves behind: the admin's input
// and its preview copy are composing artefacts, not the broadcast.
t.clearBroadcastDraft(runner.actor, draft.MessageIDs, nil)
logger.Info("broadcast finished: delivered", sent, "failed", failed, "skipped", result.Skipped, "elapsed", result.Elapsed)
runner.setResult(result)
}
@@ -397,13 +497,15 @@ func (t *Tgbot) broadcastSummaryText(result broadcastResult) string {
// the card is gone and the summary needs its own message to be seen at all.
func (t *Tgbot) finalizeBroadcastCard(runner *broadcastRunner, summary string) bool {
params := telego.EditMessageTextParams{
ChatID: tu.ID(runner.chatID),
MessageID: runner.messageID,
Text: summary,
ParseMode: "HTML",
ReplyMarkup: &telego.InlineKeyboardMarkup{InlineKeyboard: [][]telego.InlineKeyboardButton{}},
ChatID: tu.ID(runner.chatID),
MessageID: runner.messageID,
Text: summary,
ParseMode: "HTML",
// The run is over: its own controls go, and what remains is the same
// "🙈 Скрыть" every other finished message offers.
ReplyMarkup: t.hideButton(),
}
_, err := bot.EditMessageText(context.Background(), &params)
_, err := liveBot().EditMessageText(context.Background(), &params)
if err == nil || isTelegramNotModifiedError(err) {
return true
}
@@ -420,8 +522,13 @@ func (t *Tgbot) cancelBroadcast(actor chatUser, messageID int, queryID string) {
t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.answers.broadcastCanceling"))
return
}
// Dropping a broadcast before it ran removes the admin's input, its preview
// copy and both prompt cards, leaving the chat as it was before /broadcast.
inputIDs := broadcastComposeInputs(actor)
broadcastDropCompose(actor)
userStateMgr.clear(actor)
t.clearBroadcastPrompt(actor)
t.clearBroadcastDraft(actor, inputIDs, nil)
t.deleteMessageTgBot(chatId, messageID)
t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.answers.broadcastCanceled"))
}
@@ -454,19 +561,19 @@ func (t *Tgbot) collectBroadcastRecipients() []int64 {
// broadcastDeliverOne retries a recipient through flood-control waits so a 429
// never drops them; after broadcastFloodRetries waits it gives up on them.
func broadcastDeliverOne(chatID int64, draft broadcastDraft, aborted func() bool) error {
func broadcastDeliverOne(chatID int64, draft broadcastDraft, aborted func() bool) ([]int, error) {
for attempt := 0; ; attempt++ {
err := broadcastSender(chatID, draft)
ids, err := broadcastSender(chatID, draft)
if err == nil {
return nil
return ids, nil
}
wait, flood := broadcastRetryAfter(err)
if !flood || attempt >= broadcastFloodRetries {
return err
return nil, err
}
logger.Warningf("broadcast: chat %d is flood-limited, retrying in %s", chatID, wait)
if !broadcastFloodWait(wait, aborted) {
return errBroadcastAborted
return nil, errBroadcastAborted
}
}
}
@@ -502,20 +609,50 @@ func broadcastRetryAfter(err error) (time.Duration, bool) {
return time.Duration(apiErr.Parameters.RetryAfter) * time.Second, true
}
// deliverBroadcastCopy copies the admin's message to one recipient chat; an
// album rides one copyMessages call and arrives with no forward header.
func deliverBroadcastCopy(chatID int64, draft broadcastDraft) error {
// deliverBroadcastCopy copies the admin's message into one chat (an album rides
// one copyMessages call, no forward header) and returns the created ids.
func deliverBroadcastCopy(chatID int64, draft broadcastDraft) ([]int, error) {
from := tu.ID(draft.FromChatID)
return callTelegramAPI(func(ctx context.Context) error {
var ids []int
err := callTelegramAPI(func(ctx context.Context) error {
var err error
switch {
case len(draft.MessageIDs) > 1:
_, err = bot.CopyMessages(ctx, &telego.CopyMessagesParams{ChatID: tu.ID(chatID), FromChatID: from, MessageIDs: draft.MessageIDs})
var copied []telego.MessageID
copied, err = liveBot().CopyMessages(ctx, &telego.CopyMessagesParams{ChatID: tu.ID(chatID), FromChatID: from, MessageIDs: draft.MessageIDs})
for _, m := range copied {
ids = append(ids, m.MessageID)
}
case len(draft.MessageIDs) == 1:
_, err = bot.CopyMessage(ctx, &telego.CopyMessageParams{ChatID: tu.ID(chatID), FromChatID: from, MessageID: draft.MessageIDs[0]})
var copied *telego.MessageID
copied, err = liveBot().CopyMessage(ctx, &telego.CopyMessageParams{ChatID: tu.ID(chatID), FromChatID: from, MessageID: draft.MessageIDs[0]})
if copied != nil {
ids = append(ids, copied.MessageID)
}
}
return err
})
// Every recipient can put the message away (a client has no panel menu to
// clear it from); the content is untouched, only the keyboard is added.
if err == nil {
for _, id := range ids {
markupBroadcastCopy(chatID, id)
}
}
return ids, err
}
// markupBroadcastCopy puts the hide button on a delivered copy. Best-effort: a
// failure here leaves the broadcast intact and is not worth retrying the send.
func markupBroadcastCopy(chatID int64, messageID int) {
_ = callTelegramAPI(func(ctx context.Context) error {
_, err := liveBot().EditMessageReplyMarkup(ctx, &telego.EditMessageReplyMarkupParams{
ChatID: tu.ID(chatID),
MessageID: messageID,
ReplyMarkup: hideButtonMarkup(),
})
return err
})
}
func callTelegramAPI(call func(ctx context.Context) error) error {
@@ -41,6 +41,11 @@ func newBroadcastMock(t *testing.T) (url string, calls func(string) int, bodies
if method == "copyMessages" {
result = []any{message, message}
}
if method == "sendPhoto" {
// Screens are photo messages whose caption and keyboard travel as form fields; the mock lifts them into
// the JSON shape the assertions read.
payload["text"] = payload["caption"]
}
mu.Lock()
counts[method]++
sent[method] = append(sent[method], payload)
@@ -88,7 +93,7 @@ func setBroadcastRunning(t *testing.T, running bool) {
})
}
func swapBroadcastSender(t *testing.T, sender func(int64, broadcastDraft) error, pause func(time.Duration)) {
func swapBroadcastSender(t *testing.T, sender func(int64, broadcastDraft) ([]int, error), pause func(time.Duration)) {
t.Helper()
origSend, origPause := broadcastSender, broadcastPause
t.Cleanup(func() {
@@ -226,8 +231,11 @@ func TestRunBroadcastCounters(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) error {
return tt.outcomes[chatID]
swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) ([]int, error) {
if err := tt.outcomes[chatID]; err != nil {
return nil, err
}
return []int{int(chatID)}, nil
}, func(time.Duration) {})
runner := &broadcastRunner{chatID: 100, messageID: 5}
@@ -259,11 +267,11 @@ func TestRunBroadcastCancelsMidway(t *testing.T) {
setBroadcastRunning(t, true)
runner := &broadcastRunner{chatID: 100, messageID: 5}
swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) error {
swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) ([]int, error) {
if chatID == 1 {
runner.cancel.Store(true)
}
return nil
return []int{int(chatID)}, nil
}, func(time.Duration) {})
(&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3, 4, 5})
@@ -288,7 +296,7 @@ func TestRunBroadcastUnreachableKeepsProgressThrottled(t *testing.T) {
swapTestBot(t, url)
setBroadcastRunning(t, true)
blocked := &telegoapi.Error{ErrorCode: 403, Description: "Forbidden: bot can't initiate conversation with a user"}
swapBroadcastSender(t, func(int64, broadcastDraft) error { return blocked }, func(time.Duration) {})
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { return nil, blocked }, func(time.Duration) {})
runner := &broadcastRunner{chatID: 100, messageID: 5}
(&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3, 4, 5})
@@ -321,7 +329,7 @@ func TestBroadcastFloodWaitSlicesLongWaits(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
var mu sync.Mutex
var pauses []time.Duration
swapBroadcastSender(t, func(int64, broadcastDraft) error { return flood }, func(d time.Duration) {
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { return nil, flood }, func(d time.Duration) {
mu.Lock()
defer mu.Unlock()
pauses = append(pauses, d)
@@ -335,7 +343,7 @@ func TestBroadcastFloodWaitSlicesLongWaits(t *testing.T) {
return checks > tt.abortAfter
}
err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, aborted)
_, err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, aborted)
mu.Lock()
defer mu.Unlock()
@@ -378,7 +386,7 @@ func TestStopBotResetsBroadcastState(t *testing.T) {
MessageID: 1,
MediaGroupID: "grpR",
})
runner := broadcastRegisterRunner(chatID)
runner := broadcastRegisterRunner(chatUser{chatID: chatID, userID: chatID})
if runner == nil {
t.Fatal("broadcastRegisterRunner() = nil before the stop")
}
@@ -408,7 +416,7 @@ func TestRunBroadcastAlbumPacing(t *testing.T) {
setBroadcastRunning(t, true)
var pauses []time.Duration
swapBroadcastSender(t, func(int64, broadcastDraft) error { return nil }, func(d time.Duration) {
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { return nil, nil }, func(d time.Duration) {
pauses = append(pauses, d)
})
@@ -469,14 +477,17 @@ func TestBroadcastDeliverOneRetries429(t *testing.T) {
var mu sync.Mutex
callNum := 0
var pauses []time.Duration
swapBroadcastSender(t, func(int64, broadcastDraft) error {
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) {
mu.Lock()
defer mu.Unlock()
callNum++
if callNum > len(tt.responses) {
return nil
return []int{callNum}, nil
}
return tt.responses[callNum-1]
if err := tt.responses[callNum-1]; err != nil {
return nil, err
}
return []int{callNum}, nil
}, func(d time.Duration) {
mu.Lock()
defer mu.Unlock()
@@ -484,7 +495,7 @@ func TestBroadcastDeliverOneRetries429(t *testing.T) {
})
canceled := func() bool { return tt.canceled }
err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, canceled)
_, err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, canceled)
mu.Lock()
defer mu.Unlock()
@@ -511,13 +522,13 @@ func TestBroadcastDeliverOneRetries429(t *testing.T) {
}
func TestBroadcastRegisterRunnerSingleSlot(t *testing.T) {
first := broadcastRegisterRunner(1)
first := broadcastRegisterRunner(chatUser{chatID: 1})
if first == nil {
t.Fatal("broadcastRegisterRunner() = nil for an idle bot")
}
t.Cleanup(func() { broadcastUnregisterRunner(first) })
if second := broadcastRegisterRunner(2); second != nil {
if second := broadcastRegisterRunner(chatUser{chatID: 2}); second != nil {
t.Fatalf("broadcastRegisterRunner() = %v while a broadcast is running, want nil", second)
}
@@ -532,7 +543,7 @@ func TestStartBroadcastRefusesWhileRunning(t *testing.T) {
const chatID = int64(9102)
resetBroadcastState(t)
runner := broadcastRegisterRunner(chatID)
runner := broadcastRegisterRunner(chatUser{chatID: chatID, userID: chatID})
t.Cleanup(func() {
broadcastUnregisterRunner(runner)
})
@@ -615,7 +626,7 @@ func TestDeliverBroadcastCopy(t *testing.T) {
url, calls, bodies := newBroadcastMock(t)
swapTestBot(t, url)
if err := deliverBroadcastCopy(66, tt.draft); err != nil {
if _, err := deliverBroadcastCopy(66, tt.draft); err != nil {
t.Fatalf("deliverBroadcastCopy() error = %v", err)
}
for method, want := range tt.wantMethods {
@@ -939,3 +950,83 @@ func TestBroadcastCallbacksDeniedToNonAdmin(t *testing.T) {
}
}
}
// broadcastComposeToken reads the token the pending draft was stored under.
func broadcastComposeToken(t *testing.T, actor chatUser) string {
t.Helper()
_, token, ok := broadcastPendingDraft(actor)
if !ok {
t.Fatal("no pending draft to confirm")
}
return token
}
// The admin's composing message is the broadcast's source, not the broadcast: keeping it, its preview
// and the card put three copies of the text on screen.
func TestBroadcastCleansUpItsComposingMessages(t *testing.T) {
srv, calls := recordingServer(t, false)
swapTestBot(t, srv.URL)
t.Cleanup(srv.Close)
broadcastLocalizer(t)
setBroadcastRunning(t, true)
t.Cleanup(func() { resetBroadcastState(t) })
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) {
// The id Telegram returns for the self-copy preview.
return []int{321}, nil
}, func(time.Duration) {})
tb := initReportDB(t)
const chatID = int64(9100)
admin := chatUser{chatID: chatID, userID: 1}
tb.handleBroadcastInput(&telego.Message{
MessageID: 777,
Chat: telego.Chat{ID: chatID},
From: &telego.User{ID: 1},
Text: "hello everyone",
}, admin)
// The preview copy is the broadcast content: it must leave the chat at once.
if got := countMethod(calls(), "deleteMessage"); got == 0 {
t.Fatal("the self-copy preview was left in the chat")
}
// Confirming must also remove the admin's own input message.
tb.confirmBroadcast(admin, broadcastComposeToken(t, admin), 55, "q1")
if got := countMethod(calls(), "deleteMessage"); got < 2 {
t.Errorf("deleteMessage calls = %d, want the input and the preview removed", got)
}
}
// A cancelled broadcast must not leave its copies in the recipients' chats: the
// summary would say it stopped while every chat that got one still shows it.
func TestCancelledBroadcastRecallsDeliveredCopies(t *testing.T) {
srv, calls := recordingServer(t, false)
swapTestBot(t, srv.URL)
t.Cleanup(srv.Close)
broadcastLocalizer(t)
setBroadcastRunning(t, true)
t.Cleanup(func() { resetBroadcastState(t) })
initReportDB(t)
runner := &broadcastRunner{actor: chatUser{chatID: 100}, chatID: 100, messageID: 5}
delivered := 0
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) {
delivered++
// The admin cancels mid-run: what already went out has to be withdrawn.
if delivered == 2 {
runner.cancel.Store(true)
}
return []int{900 + delivered}, nil
}, func(time.Duration) {})
(&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3})
if delivered == 0 {
t.Fatal("nothing was delivered, so there is nothing to recall")
}
if got := countMethod(calls(), "deleteMessage"); got < delivered {
t.Errorf("deleteMessage calls = %d, want one per delivered copy (%d)", got, delivered)
}
}
+24 -293
View File
@@ -1,8 +1,6 @@
package tgbot
import (
"context"
"encoding/json"
"errors"
"fmt"
"html"
@@ -20,7 +18,6 @@ import (
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
"github.com/skip2/go-qrcode"
)
// BuildClientDraftMessage builds a protocol-neutral summary of the in-progress
@@ -224,28 +221,6 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
return subURL, subJsonURL, nil
}
// sendClientSubLinks sends the subscription links for the client to the chat.
func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
}
msg := "Subscription URL:\r\n<code>" + subURL + "</code>"
if subJsonURL != "" {
msg += "\r\n\r\nJSON URL:\r\n<code>" + subJsonURL + "</code>"
}
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links "+email)),
),
)
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
}
// clientSubLinks builds the subscription's links in-process for the host subURL
// names; fetching subURL instead fails whenever that host does not resolve here.
func (t *Tgbot) clientSubLinks(email, subURL string) ([]string, error) {
@@ -260,105 +235,6 @@ func (t *Tgbot) clientSubLinks(email, subURL string) ([]string, error) {
return t.inboundService.GetSubLinks(u.Hostname(), client.SubID)
}
// sendClientIndividualLinks sends the subscription's individual links to the user
func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
subURL, _, err := t.buildSubscriptionURLs(email)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
}
links, err := t.clientSubLinks(email, subURL)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
}
if len(links) == 0 {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult"))
return
}
// Send in chunks to respect message length; use monospace formatting
const maxPerMessage = 50
for i := 0; i < len(links); i += maxPerMessage {
j := min(i+maxPerMessage, len(links))
chunk := links[i:j]
var msg strings.Builder
msg.WriteString(t.I18nBot("subscription.individualLinks"))
msg.WriteString(":\r\n")
for _, link := range chunk {
// wrap each link in <code>
msg.WriteString("<code>")
msg.WriteString(link)
msg.WriteString("</code>\r\n")
}
t.SendMsgToTgbot(chatId, msg.String())
}
}
// sendClientQRLinks generates QR images for subscription URL, JSON URL, and a few individual links, then sends them
func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
}
// Helper to create QR PNG bytes from content
createQR := func(content string, size int) ([]byte, error) {
if size <= 0 {
size = 256
}
return qrcode.Encode(content, qrcode.Medium, size)
}
// Inform user
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.qrCodeForClient", "Email=="+email))
// Send sub URL QR (filename: sub.png)
if png, err := createQR(subURL, 320); err == nil {
document := tu.Document(
tu.ID(chatId),
tu.FileFromBytes(png, "sub.png"),
)
_, _ = bot.SendDocument(context.Background(), inAdminTopic(document))
} else {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
}
// Send JSON URL QR (filename: subjson.png) when available
if subJsonURL != "" {
if png, err := createQR(subJsonURL, 320); err == nil {
document := tu.Document(
tu.ID(chatId),
tu.FileFromBytes(png, "subjson.png"),
)
_, _ = bot.SendDocument(context.Background(), inAdminTopic(document))
} else {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
}
}
// Also generate a few individual links' QRs (first up to 5)
if links, err := t.clientSubLinks(email, subURL); err == nil {
max := min(len(links), 5)
for i := range max {
if png, err := createQR(links[i], 320); err == nil {
// Use the email as filename for individual link QR
filename := email + ".png"
document := tu.Document(
tu.ID(chatId),
tu.FileFromBytes(png, filename),
)
_, _ = bot.SendDocument(context.Background(), inAdminTopic(document))
// Reduced delay for better performance
if i < max-1 { // Only delay between documents, not after the last one
time.Sleep(50 * time.Millisecond)
}
}
}
}
}
// clientInfoMsg formats client information message based on traffic and flags.
func (t *Tgbot) clientInfoMsg(
traffic *xray.ClientTraffic,
@@ -503,13 +379,20 @@ func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) {
return
}
// A client sees only its own card, and never the admin keyboard: every button
// on it (reset traffic, limits, IP limit, enable/disable) acts on an account.
if t.levelOf(tgUserID) != levelAdmin {
t.screenClientHome(chatId, tgUserID)
return
}
output := ""
if len(traffics) > 0 {
if len(email) > 0 {
for _, traffic := range traffics {
if traffic.Email == email[0] {
output := t.clientInfoMsg(traffic, true, true, true, true, true, true)
output := t.clientInfoMsg(traffic, true, true, true, true, true, false)
t.SendMsgToTgbot(chatId, output)
return
}
@@ -526,169 +409,13 @@ func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) {
}
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
t.SendMsgToTgbot(chatId, output)
output = t.I18nBot("tgbot.commands.pleaseChoose")
t.SendAnswer(chatId, output, false)
}
// searchClientIps searches and sends client IP addresses for the given email.
func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
ips, err := t.inboundService.GetInboundClientIps(email)
if err != nil || len(ips) == 0 {
ips = t.I18nBot("tgbot.noIpRecord")
}
formattedIps := ips
if err == nil && len(ips) > 0 {
type ipWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var ipsWithTime []ipWithTimestamp
if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 {
lines := make([]string, 0, len(ipsWithTime))
for _, item := range ipsWithTime {
if item.IP == "" {
continue
}
if item.Timestamp > 0 {
ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05")
lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts))
continue
}
lines = append(lines, item.IP)
}
if len(lines) > 0 {
formattedIps = strings.Join(lines, "\n")
}
} else {
var oldIps []string
if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 {
formattedIps = strings.Join(oldIps, "\n")
}
}
}
output := ""
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps)
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("ips_refresh "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clearIPs")).WithCallbackData(t.encodeQuery("clear_ips "+email)),
),
)
if len(messageID) > 0 {
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
} else {
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
}
}
// clientTelegramUserInfo retrieves and sends Telegram user info for the client.
func (t *Tgbot) clientTelegramUserInfo(chatId int64, email string, messageID ...int) {
traffic, client, err := t.inboundService.GetClientByEmail(email)
if err != nil {
logger.Warning(err)
msg := t.I18nBot("tgbot.wentWrong")
t.SendMsgToTgbot(chatId, msg)
return
}
if client == nil {
msg := t.I18nBot("tgbot.noResult")
t.SendMsgToTgbot(chatId, msg)
return
}
tgId := "None"
if client.TgID != 0 {
tgId = strconv.FormatInt(client.TgID, 10)
}
output := ""
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
output += t.I18nBot("tgbot.messages.TGUser", "TelegramID=="+tgId)
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("tgid_refresh "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.removeTGUser")).WithCallbackData(t.encodeQuery("tgid_remove "+email)),
),
)
if len(messageID) > 0 {
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
} else {
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
requestUser := telego.KeyboardButtonRequestUsers{
RequestID: int32(traffic.Id),
UserIsBot: new(bool),
}
keyboard := tu.Keyboard(
tu.KeyboardRow(
tu.KeyboardButton(t.I18nBot("tgbot.buttons.selectTGUser")).WithRequestUsers(&requestUser),
),
tu.KeyboardRow(
tu.KeyboardButton(t.I18nBot("tgbot.buttons.closeKeyboard")),
),
).WithIsPersistent().WithResizeKeyboard()
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.buttons.selectOneTGUser"), keyboard)
}
}
// searchClient searches for a client by email and sends the information.
func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
if err != nil {
logger.Warning(err)
msg := t.I18nBot("tgbot.wentWrong")
t.SendMsgToTgbot(chatId, msg)
return
}
if traffic == nil {
msg := t.I18nBot("tgbot.noResult")
t.SendMsgToTgbot(chatId, msg)
return
}
output := t.clientInfoMsg(traffic, true, true, true, true, true, true)
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("client_refresh "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetTraffic")).WithCallbackData(t.encodeQuery("reset_traffic "+email)),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData(t.encodeQuery("limit_traffic "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData(t.encodeQuery("reset_exp "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLog")).WithCallbackData(t.encodeQuery("ip_log "+email)),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData(t.encodeQuery("ip_limit "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData(t.encodeQuery("tg_user "+email)),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.inviteLink")).WithCallbackData(t.encodeQuery("client_invite_link "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.toggle")).WithCallbackData(t.encodeQuery("toggle_enable "+email)),
),
)
if len(messageID) > 0 {
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
} else {
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
rows := [][]telego.InlineKeyboardButton{}
if len(email) > 0 {
rows = t.clientRows(email[0])
} else if len(traffics) > 0 {
rows = t.clientRows(traffics[0].Email)
}
t.renderScreen(chatId, t.newScreen("client", output, rows...))
}
// getCommonClientButtons returns the shared inline keyboard rows for the
@@ -696,7 +423,8 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
// flow, method) are generated by fillProtocolDefaults on submit, so the bot
// only exposes the universal client fields here.
func (t *Tgbot) getCommonClientButtons(draft *clientDraft) [][]telego.InlineKeyboardButton {
attachLabel := fmt.Sprintf("➕ Attach inbound (%d)", len(draft.receiverInboundIDs))
attachLabel := t.I18nBot("tgbot.buttons.attachInbound",
"Count=="+strconv.Itoa(len(draft.receiverInboundIDs)))
return [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
@@ -723,12 +451,15 @@ func (t *Tgbot) getCommonClientButtons(draft *clientDraft) [][]telego.InlineKeyb
}
}
// addClient renders the draft message + shared client-first keyboard.
// addClient renders the draft as the chat's screen: the wizard keeps ONE
// message, and a value picker can edit it without losing the card.
func (t *Tgbot) addClient(chatId int64, draft *clientDraft, msg string, messageID ...int) {
inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons(draft)...)
if len(messageID) > 0 {
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
} else {
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
t.adoptScreen(chatId, messageID[0])
}
body := msg
if body == "" {
body = t.BuildClientDraftMessage(draft)
}
t.renderScreen(chatId, t.newScreen("wizard", body, t.getCommonClientButtons(draft)...))
}
@@ -2,7 +2,6 @@ package tgbot
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -23,23 +22,15 @@ func draftTexts(t *testing.T) (string, func(int64) []string) {
var mu sync.Mutex
texts := map[int64][]string{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
result := any(true)
if r.URL.Path == "/bot"+testBotToken+"/sendMessage" || r.URL.Path == "/bot"+testBotToken+"/editMessageText" {
var payload struct {
ChatID any `json:"chat_id"`
Text string `json:"text"`
}
_ = json.Unmarshal(body, &payload)
chatID := int64(0)
switch v := payload.ChatID.(type) {
case float64:
chatID = int64(v)
}
switch method {
case "sendMessage", "sendPhoto", "editMessageText", "editMessageMedia", "editMessageCaption":
sent := extractSentBody(t, r)
mu.Lock()
texts[chatID] = append(texts[chatID], payload.Text)
texts[sent.ChatID] = append(texts[sent.ChatID], sent.Text)
mu.Unlock()
result = map[string]any{"message_id": 1, "date": 0, "chat": map[string]any{"id": chatID, "type": "private"}}
result = map[string]any{"message_id": 1, "date": 0, "chat": map[string]any{"id": sent.ChatID, "type": "private"}}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result})
@@ -31,6 +31,14 @@ func newLinksCallbackTgbot(t *testing.T, email string) (*Tgbot, func(string) int
"date": 0,
"chat": map[string]any{"id": ownerTgID, "type": "private"},
}},
// The owner's links draw on the tracked screen, which is a photo.
"sendPhoto": map[string]any{"ok": true, "result": map[string]any{
"message_id": 1,
"date": 0,
"chat": map[string]any{"id": ownerTgID, "type": "private"},
}},
"deleteMessage": map[string]any{"ok": true, "result": true},
"answerInlineQuery": map[string]any{"ok": true, "result": true},
})
swapTestBot(t, mock.URL)
t.Cleanup(mock.Close)
@@ -87,8 +95,8 @@ func TestClientLinkCallbackRefusesForeignClient(t *testing.T) {
tapClientLinks(t, tb, ownerTgID, "client_sub_links someone-else@x")
if n := calls("sendMessage"); n != 0 {
t.Errorf("sendMessage calls = %d, want 0: a non-admin received a foreign client's links", n)
if n := calls("sendMessage") + calls("sendPhoto"); n != 0 {
t.Errorf("message calls = %d, want 0: a non-admin received a foreign client's links", n)
}
if n := calls("answerCallbackQuery"); n != 1 {
t.Errorf("answerCallbackQuery calls = %d, want 1: the refused tap must be answered", n)
@@ -101,8 +109,8 @@ func TestClientLinkCallbackServesOwnClient(t *testing.T) {
tapClientLinks(t, tb, ownerTgID, "client_sub_links "+ownerMail)
if n := calls("sendMessage"); n != 1 {
t.Errorf("sendMessage calls = %d, want 1: the owner must still get its links", n)
if n := calls("sendMessage") + calls("sendPhoto"); n != 1 {
t.Errorf("message calls = %d, want 1: the owner must still get its links", n)
}
if n := calls("answerCallbackQuery"); n != 0 {
t.Errorf("answerCallbackQuery calls = %d, want 0: an allowed tap is not refused", n)
@@ -144,7 +152,7 @@ func TestHashedLinkCallbackServesOwnClient(t *testing.T) {
}
tapClientLinks(t, tb, ownerTgID, data)
if n := calls("sendMessage"); n != 1 {
t.Errorf("sendMessage calls = %d, want 1: the owner's hashed button must still be served", n)
if n := calls("sendMessage") + calls("sendPhoto"); n != 1 {
t.Errorf("message calls = %d, want 1: the owner's hashed button must still be served", n)
}
}
@@ -1,18 +0,0 @@
package tgbot
import "testing"
func TestDeleteMessageAfterDelayKeepsUserState(t *testing.T) {
userStateMgr.reset()
t.Cleanup(userStateMgr.reset)
const chatID = int64(4242)
userStateMgr.set(chatUser{chatID: chatID, userID: 1}, "awaiting_comment")
tg := &Tgbot{}
tg.deleteMessageAfterDelay(chatID, 1, 0)
if st, ok := userStateMgr.get(chatUser{chatID: chatID, userID: 1}); !ok || st != "awaiting_comment" {
t.Fatalf("delayed message deletion cleared the conversation state: got (%q, %v), want (%q, true)", st, ok, "awaiting_comment")
}
}
+19 -12
View File
@@ -5,7 +5,6 @@ import (
"os"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
)
@@ -25,23 +24,31 @@ func getHostname() string {
return cachedHostname
}
var tgEventLimiter = eventbus.NewRateLimiter(1 * time.Minute)
// HandleEvent is the eventbus subscriber callback. It formats incoming events
// as Telegram messages and sends them to all admin chats.
// HandleEvent is the eventbus subscriber callback: one kind of event keeps ONE
// live card per admin chat, so a repeat edits it instead of a wall of messages.
func (t *Tgbot) HandleEvent(e eventbus.Event) {
if !t.isEventEnabled(e.Type) {
return
}
if e.Type != eventbus.EventLoginAttempt {
if !tgEventLimiter.Allow(e.Type, e.Source) {
return
msg := t.formatEventMessage(e)
if msg == "" {
return
}
kind := t.eventNoticeKind(e)
for _, adminID := range adminSnapshot() {
t.liveNotice(adminID, kind, msg)
}
}
// eventNoticeKind is the live card a repeat of this event edits. A login success
// must not fold into the failure card from the same IP: outcome is part of the key.
func (t *Tgbot) eventNoticeKind(e eventbus.Event) string {
if e.Type == eventbus.EventLoginAttempt {
if data, ok := e.Data.(*eventbus.LoginEventData); ok {
return string(e.Type) + ":" + e.Source + ":" + data.Status
}
}
msg := t.formatEventMessage(e)
if msg != "" {
t.SendMsgToTgbotAdmins(msg)
}
return string(e.Type) + ":" + e.Source
}
func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool {
@@ -0,0 +1,130 @@
package tgbot
import (
"context"
"testing"
"github.com/mymmrac/telego"
th "github.com/mymmrac/telego/telegohandler"
)
const testAdminID = 1
func privateMessage(id int, text string) telego.Update {
return telego.Update{UpdateID: id, Message: &telego.Message{
MessageID: id, Text: text,
Chat: telego.Chat{ID: testAdminID, Type: telego.ChatTypePrivate},
From: &telego.User{ID: testAdminID, FirstName: "a"},
}}
}
func groupMessage(id int, text string) telego.Update {
return telego.Update{UpdateID: id, Message: &telego.Message{
MessageID: id, Text: text,
Chat: telego.Chat{ID: -900, Type: telego.ChatTypeSupergroup},
From: &telego.User{ID: testAdminID, FirstName: "a"},
}}
}
func groupCallback(id int, data string) telego.Update {
return telego.Update{UpdateID: id, CallbackQuery: &telego.CallbackQuery{
ID: "cq", Data: data,
From: telego.User{ID: testAdminID, FirstName: "a"},
Message: &telego.Message{
MessageID: id,
Chat: telego.Chat{ID: -900, Type: telego.ChatTypeSupergroup},
},
}}
}
// A group shares one screen and keyboard among all members, so admins and clients overwrite each other;
// the guard must answer a group and stop it before any screen is drawn.
func TestGroupUpdatesAreRefused(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
h, err := th.NewBotHandler(liveBot(), make(chan telego.Update, 1))
if err != nil {
t.Fatalf("NewBotHandler: %v", err)
}
refuseGroups(h, tb)
for _, update := range []telego.Update{
groupMessage(1, "/start"),
groupCallback(2, cbHome),
{UpdateID: 3, InlineQuery: &telego.InlineQuery{ID: "q", ChatType: telego.ChatTypeGroup}},
} {
if err := h.BaseGroup().HandleUpdate(context.Background(), liveBot(), update); err != nil {
t.Fatalf("HandleUpdate: %v", err)
}
}
got := calls()
if indexOf(got, "sendPhoto") >= 0 || indexOf(got, "editMessageText") >= 0 {
t.Fatalf("a group drew a screen: %v", methods(got))
}
want := tb.I18nBot("tgbot.messages.privateOnly")
sawNotice := false
for _, c := range got {
if c.Method == "sendMessage" && c.text() == want {
sawNotice = true
}
}
if !sawNotice {
t.Fatalf("the group was not told why nothing happened: %v", methods(got))
}
if indexOf(got, "answerCallbackQuery") < 0 {
t.Fatalf("the group callback was left unanswered: %v", methods(got))
}
if indexOf(got, "answerInlineQuery") < 0 {
t.Fatalf("the group inline query was left unanswered: %v", methods(got))
}
}
// A private update must fall through the guard untouched: the close-keyboard
// route runs synchronously, so its delete is visible without a worker goroutine.
func TestPrivateChatIsNotRefused(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
h, err := th.NewBotHandler(liveBot(), make(chan telego.Update, 1))
if err != nil {
t.Fatalf("NewBotHandler: %v", err)
}
refuseGroups(h, tb)
h.HandleMessage(func(*th.Context, telego.Message) error { return nil },
th.TextEqual(tb.I18nBot("tgbot.buttons.closeKeyboard")))
if err := h.BaseGroup().HandleUpdate(context.Background(), liveBot(),
privateMessage(1, tb.I18nBot("tgbot.buttons.closeKeyboard"))); err != nil {
t.Fatalf("HandleUpdate: %v", err)
}
got := calls()
for _, c := range got {
if c.text() == tb.I18nBot("tgbot.messages.privateOnly") {
t.Fatalf("a private chat was refused: %v", methods(got))
}
}
}
// updateChatType is the guard's only input, so it is pinned per update shape.
func TestUpdateChatType(t *testing.T) {
cases := []struct {
name string
up telego.Update
want string
}{
{"private message", privateMessage(1, "x"), telego.ChatTypePrivate},
{"group message", groupMessage(1, "x"), telego.ChatTypeSupergroup},
{"inline from private chat (sender)", telego.Update{InlineQuery: &telego.InlineQuery{
ID: "q", ChatType: "sender",
}}, telego.ChatTypePrivate},
{"inline from a group", telego.Update{InlineQuery: &telego.InlineQuery{
ID: "q", ChatType: telego.ChatTypeGroup,
}}, telego.ChatTypeGroup},
{"empty inline chat type", telego.Update{InlineQuery: &telego.InlineQuery{ID: "q"}}, telego.ChatTypePrivate},
{"unknown update", telego.Update{}, ""},
}
for _, tc := range cases {
if got := updateChatType(tc.up); got != tc.want {
t.Errorf("%s: updateChatType = %q, want %q", tc.name, got, tc.want)
}
}
}
+12 -162
View File
@@ -44,98 +44,6 @@ func (t *Tgbot) getInboundUsages() string {
return info.String()
}
// getInbounds creates an inline keyboard with all inbounds.
func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) {
inbounds, err := t.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("GetAllInbounds run failed:", err)
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
if len(inbounds) == 0 {
logger.Warning("No inbounds found")
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
var buttons []telego.InlineKeyboardButton
for _, inbound := range inbounds {
status := "❌"
if inbound.Enable {
status = "✅"
}
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "get_clients", inbound.Id))
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
}
cols := 1
if len(buttons) >= 6 {
cols = 2
}
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
return keyboard, nil
}
// getInboundsFor builds an inline keyboard of inbounds for a custom next action.
func (t *Tgbot) getInboundsFor(nextAction string) (*telego.InlineKeyboardMarkup, error) {
inbounds, err := t.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("GetAllInbounds run failed:", err)
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
if len(inbounds) == 0 {
logger.Warning("No inbounds found")
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
var buttons []telego.InlineKeyboardButton
for _, inbound := range inbounds {
status := "❌"
if inbound.Enable {
status = "✅"
}
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", nextAction, inbound.Id))
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
}
cols := 1
if len(buttons) >= 6 {
cols = 2
}
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
return keyboard, nil
}
// getInboundClientsFor lists clients of an inbound with a specific action prefix to be appended with email
func (t *Tgbot) getInboundClientsFor(inbound *model.Inbound, action string) (*telego.InlineKeyboardMarkup, error) {
clients, err := t.inboundService.GetClients(inbound)
var buttons []telego.InlineKeyboardButton
if err != nil {
logger.Warning("GetInboundClients run failed:", err)
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
} else {
if len(clients) > 0 {
for _, client := range clients {
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email)))
}
} else {
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
}
}
cols := 0
if len(buttons) < 6 {
cols = 3
} else {
cols = 2
}
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
return keyboard, nil
}
// addClientExcludedProtocols are the protocols with no per-client model: Tunnel
// has no clients, Mixed/HTTP authenticate at the inbound level, not per-client.
var addClientExcludedProtocols = map[model.Protocol]bool{
@@ -144,47 +52,6 @@ var addClientExcludedProtocols = map[model.Protocol]bool{
model.HTTP: true,
}
// getInboundsAddClient creates an inline keyboard for adding clients to inbounds.
func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
inbounds, err := t.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("GetAllInbounds run failed:", err)
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
if len(inbounds) == 0 {
logger.Warning("No inbounds found")
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
var buttons []telego.InlineKeyboardButton
for _, inbound := range inbounds {
if addClientExcludedProtocols[inbound.Protocol] {
continue
}
status := "❌"
if inbound.Enable {
status = "✅"
}
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "add_client_to", inbound.Id))
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
}
if len(buttons) == 0 {
logger.Warning("No inbounds eligible for add-client (all excluded by protocol)")
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
cols := 1
if len(buttons) >= 6 {
cols = 2
}
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
return keyboard, nil
}
// getInboundsAttachPicker builds a toggle picker over multi-client inbounds
// for the "attach more inbounds to the new client" step. Each row shows the
// current selection state for the inbound; tapping fires
@@ -222,42 +89,25 @@ func (t *Tgbot) getInboundsAttachPicker(draft *clientDraft) (*telego.InlineKeybo
}
rows := tu.InlineKeyboardCols(cols, buttons...)
rows = append(rows, tu.InlineKeyboardRow(
tu.InlineKeyboardButton("✅ Done").WithCallbackData(t.encodeQuery("add_client_attach_done")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.attachDone")).
WithCallbackData(t.encodeQuery("add_client_attach_done")),
))
return tu.InlineKeyboardGrid(rows), nil
}
// getInboundClients creates an inline keyboard with clients of a specific inbound.
func (t *Tgbot) getInboundClients(id int) (*telego.InlineKeyboardMarkup, error) {
inbound, err := t.inboundService.GetInbound(id)
if err != nil {
logger.Warning("getIboundClients run failed:", err)
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
// getInboundClientsFor builds the client list for the caller's inbound row: a
// second DB read would reopen the stale-row window the caller's guard closed.
func (t *Tgbot) getInboundClientsFor(inbound *model.Inbound, action string) (*telego.InlineKeyboardMarkup, error) {
clients, err := t.inboundService.GetClients(inbound)
var buttons []telego.InlineKeyboardButton
if err != nil {
logger.Warning("GetInboundClients run failed:", err)
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
} else {
if len(clients) > 0 {
for _, client := range clients {
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery("client_get_usage "+client.Email)))
}
} else {
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
}
if err != nil || len(clients) == 0 {
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
}
cols := 0
if len(buttons) < 6 {
cols = 3
} else {
cols = 2
rows := make([][]telego.InlineKeyboardButton, 0, len(clients))
for _, client := range clients {
rows = append(rows, tu.InlineKeyboardRow(tu.InlineKeyboardButton(client.Email).
WithCallbackData(t.encodeQuery(action+" "+client.Email))))
}
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
return keyboard, nil
return tu.InlineKeyboard(rows...), nil
}
// searchInbound searches for inbounds by remark and sends the results.
@@ -2,6 +2,7 @@ package tgbot
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -23,17 +24,16 @@ func recordingBotServer(t *testing.T) func() []string {
var mu sync.Mutex
var texts []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/bot"+testBotToken+"/sendMessage" {
w.WriteHeader(http.StatusNotFound)
return
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
switch method {
case "sendMessage", "sendPhoto":
// A screen is a photo message, so its caption travels as multipart
// form data; the raw body carries it either way.
raw, _ := io.ReadAll(r.Body)
mu.Lock()
texts = append(texts, string(raw))
mu.Unlock()
}
var body struct {
Text string `json:"text"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
texts = append(texts, body.Text)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
"message_id": 1, "date": 0, "chat": map[string]any{"id": ownerTgID, "type": "private"},
@@ -159,24 +159,6 @@ func decodeInvitePayload(payload string) (string, bool) {
return string(raw), true
}
func (t *Tgbot) sendInviteLink(chatId int64, email string) {
record, err := t.clientService.GetRecordByEmail(nil, email)
username := botUsername()
if err != nil || record.SubID == "" || username == "" {
logger.Warning("tgbot: invite link unavailable for", email, err)
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
return
}
payload, ok := encodeInvitePayload(record.SubID)
if !ok {
logger.Warning("tgbot: subId of", email, "is too long for a Telegram invite link")
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
return
}
link := "https://t.me/" + username + "?start=" + payload
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteLink", "Email=="+email, "Link=="+link))
}
// A subId can be short or human-readable, so claim attempts are capped per
// Telegram account: guessing stays slow, and admins hear about whoever tries.
const (
+1 -1
View File
@@ -6,7 +6,7 @@ import (
)
// userLevel decides what the bot admits to existing at all: a Telegram account
// that no admin has bound to a client must not be able to explore the bot.
// that no admin has bound to a client must not be able to explore the liveBot().
type userLevel int
const (
+93 -294
View File
@@ -1,40 +1,53 @@
package tgbot
import (
"context"
"fmt"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
)
// SendReport sends a periodic report to admin chats.
func (t *Tgbot) SendReport() {
runTime, err := t.settingService.GetTgbotRuntime()
if err == nil && len(runTime) > 0 {
msg := ""
// reportHeader opens the report: the schedule it ran on and when.
func (t *Tgbot) reportHeader(runTime string) string {
msg := ""
if runTime != "" {
msg += t.I18nBot("tgbot.messages.report", "RunTime=="+runTime)
msg += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
t.SendMsgToTgbotAdmins(msg)
}
msg += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
return msg
}
// SendReport sends the periodic report as ONE message per admin: a report that
// arrived as a pile left every part but the last one undismissable.
func (t *Tgbot) SendReport() {
if !t.IsRunning() {
return
}
runTime, err := t.settingService.GetTgbotRuntime()
if err != nil {
runTime = ""
}
body := t.reportHeader(runTime)
body += t.prepareServerUsageInfo()
if deplete := t.depleteReport(); deplete != "" {
body += "\r\n\r\n" + deplete
}
for _, adminID := range adminSnapshot() {
t.sendReport(adminID, body)
}
info := t.sendServerUsage()
t.SendMsgToTgbotAdmins(info)
t.sendExhaustedToAdmins()
// Per-client alerts stay their own dismissable messages, one live card per
// client, rather than being folded into the report.
t.notifyExhausted()
backupEnable, err := t.settingService.GetTgBotBackup()
@@ -43,7 +56,54 @@ func (t *Tgbot) SendReport() {
}
}
// SendBackupToAdmins sends a database backup to admin chats.
// reportPrev tracks each chat's last report message, so a new run replaces it.
var reportPrev = struct {
mu sync.Mutex
msg map[int64]int
}{msg: map[int64]int{}}
func reportPrevGet(chatID int64) (int, bool) {
reportPrev.mu.Lock()
defer reportPrev.mu.Unlock()
id, ok := reportPrev.msg[chatID]
return id, ok
}
func reportPrevSet(chatID int64, id int) {
reportPrev.mu.Lock()
defer reportPrev.mu.Unlock()
reportPrev.msg[chatID] = id
}
// sendReport posts the report paged and dismissable, replacing the previous run's.
func (t *Tgbot) sendReport(chatID int64, body string) {
if body == "" {
return
}
if prev, ok := reportPrevGet(chatID); ok {
t.deleteOwnMessage(chatID, prev)
}
last := 0
for _, chunk := range pageMessage(body, telegramPageLimit) {
id := t.sendNoticeReturningID(chatID, chunk)
if id != 0 {
last = id
}
}
if last != 0 {
reportPrevSet(chatID, last)
}
}
// backupSummary is the caption that travels with a scheduled backup.
func (t *Tgbot) backupSummary() string {
summary := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
summary += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
return trimCaption(summary+"\n\n"+t.depleteReport(), botCaptionLimit)
}
// SendBackupToAdmins sends the database and, when it exists, the generated Xray
// config to admin chats. A failed DB read is reported, not sent silently.
func (t *Tgbot) SendBackupToAdmins() {
if !t.IsRunning() {
return
@@ -53,9 +113,21 @@ func (t *Tgbot) SendBackupToAdmins() {
logger.Error("Error in getting db backup: ", err)
}
dbFilename := t.serverService.BackupFilename("")
config, configErr := os.ReadFile(xray.GetConfigPath())
if configErr != nil {
logger.Warning("Cannot read the Xray config for the backup: ", configErr)
}
admins := adminSnapshot()
for i, adminId := range admins {
t.sendBackupData(adminId, dbData, dbFilename)
if dbData == nil {
t.sendNotice(adminId, t.I18nBot("tgbot.messages.backupFailed"))
} else {
t.sendDocumentWithCaption(adminId, dbData, dbFilename, t.backupSummary())
time.Sleep(500 * time.Millisecond)
if configErr == nil {
t.sendDocumentWithCaption(adminId, config, "config.json", "")
}
}
// Add delay between sends to avoid Telegram rate limits
if i < len(admins)-1 {
time.Sleep(1 * time.Second)
@@ -63,38 +135,6 @@ func (t *Tgbot) SendBackupToAdmins() {
}
}
// sendExhaustedToAdmins sends notifications about exhausted clients to admins.
func (t *Tgbot) sendExhaustedToAdmins() {
if !t.IsRunning() {
return
}
for _, adminId := range adminSnapshot() {
t.getExhausted(adminId)
}
}
// getServerUsage retrieves and formats server usage information.
func (t *Tgbot) getServerUsage(chatId int64, messageID ...int) string {
info := t.prepareServerUsageInfo()
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("usage_refresh"))))
if len(messageID) > 0 {
t.editMessageTgBot(chatId, messageID[0], info, keyboard)
} else {
t.SendMsgToTgbot(chatId, info, keyboard)
}
return info
}
// Send server usage without an inline keyboard
func (t *Tgbot) sendServerUsage() string {
info := t.prepareServerUsageInfo()
return info
}
// prepareServerUsageInfo prepares the server usage information string.
func (t *Tgbot) prepareServerUsageInfo() string {
// Check if we have cached data first
@@ -191,108 +231,6 @@ func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) {
})
}
// getExhausted retrieves and sends information about exhausted clients.
func (t *Tgbot) getExhausted(chatId int64) {
trDiff := int64(0)
exDiff := int64(0)
now := time.Now().Unix() * 1000
var exhaustedInbounds []model.Inbound
var exhaustedClients []xray.ClientTraffic
var disabledInbounds []model.Inbound
var disabledClients []xray.ClientTraffic
TrafficThreshold, err := t.settingService.GetTrafficDiff()
if err == nil && TrafficThreshold > 0 {
trDiff = int64(TrafficThreshold) * 1073741824
}
ExpireThreshold, err := t.settingService.GetExpireDiff()
if err == nil && ExpireThreshold > 0 {
exDiff = int64(ExpireThreshold) * 86400000
}
inbounds, err := t.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("Unable to load Inbounds", err)
}
seenClients := make(map[string]bool)
for _, inbound := range inbounds {
if inbound.Enable {
if (inbound.ExpiryTime > 0 && (inbound.ExpiryTime-now < exDiff)) ||
(inbound.Total > 0 && (inbound.Total-(inbound.Up+inbound.Down) < trDiff)) {
exhaustedInbounds = append(exhaustedInbounds, *inbound)
}
if len(inbound.ClientStats) > 0 {
for _, client := range inbound.ClientStats {
if seenClients[client.Email] {
continue
}
seenClients[client.Email] = true
if client.Enable {
if (client.ExpiryTime > 0 && (client.ExpiryTime-now < exDiff)) ||
(client.Total > 0 && (client.Total-(client.Up+client.Down) < trDiff)) {
exhaustedClients = append(exhaustedClients, client)
}
} else {
disabledClients = append(disabledClients, client)
}
}
}
} else {
disabledInbounds = append(disabledInbounds, *inbound)
}
}
// Inbounds
output := ""
output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.inbounds"))
output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledInbounds)))
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedInbounds)))
if len(exhaustedInbounds) > 0 {
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.inbounds"))
for _, inbound := range exhaustedInbounds {
output += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
output += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
output += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))
if inbound.ExpiryTime == 0 {
output += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
} else {
output += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
}
output += "\r\n"
}
}
// Clients
exhaustedCC := len(exhaustedClients)
output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients"))
output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients)))
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedCC))
if exhaustedCC > 0 {
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.clients"))
var buttons []telego.InlineKeyboardButton
for _, traffic := range exhaustedClients {
output += t.clientInfoMsg(&traffic, true, false, false, true, true, false)
output += "\r\n"
buttons = append(buttons, tu.InlineKeyboardButton(traffic.Email).WithCallbackData(t.encodeQuery("client_get_usage "+traffic.Email)))
}
cols := 0
if exhaustedCC < 11 {
cols = 1
} else {
cols = 2
}
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
t.SendMsgToTgbot(chatId, output, keyboard)
} else {
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
t.SendMsgToTgbot(chatId, output)
}
}
// notifyExhausted sends notifications for exhausted clients.
func (t *Tgbot) notifyExhausted() {
trDiff := int64(0)
@@ -359,7 +297,9 @@ func (t *Tgbot) notifyExhausted() {
output.WriteString(t.clientInfoMsg(&traffic, true, false, false, true, true, false))
output.WriteString("\r\n")
}
t.SendMsgToTgbot(chatID, output.String())
// The alert is routine and daily: give it the same dismiss the
// rest of the bot uses instead of a message a client cannot clear.
t.sendNotice(chatID, output.String())
}
}
}
@@ -370,144 +310,3 @@ func (t *Tgbot) notifyExhausted() {
}
}
}
// onlineClients retrieves and sends information about online clients.
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
process := service.XrayProcess()
if process == nil || !process.IsRunning() {
return
}
onlines := process.GetOnlineClients()
onlinesCount := len(onlines)
output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("onlines_refresh"))))
if onlinesCount > 0 {
var buttons []telego.InlineKeyboardButton
for _, online := range onlines {
label := online
if _, inbound, err := t.inboundService.GetClientInboundByEmail(online); err == nil && inbound != nil && inbound.Remark != "" {
label = online + " - " + inbound.Remark
}
buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(t.encodeQuery("client_get_usage "+online)))
}
cols := 0
if onlinesCount < 21 {
cols = 2
} else if onlinesCount < 61 {
cols = 3
} else {
cols = 4
}
keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, tu.InlineKeyboardCols(cols, buttons...)...)
}
if len(messageID) > 0 {
t.editMessageTgBot(chatId, messageID[0], output, keyboard)
} else {
t.SendMsgToTgbot(chatId, output, keyboard)
}
}
// sendBackup sends a backup of the database and configuration files.
func (t *Tgbot) sendBackup(chatId int64) {
dbData, err := t.serverService.GetDb()
if err != nil {
logger.Error("Error in getting db backup: ", err)
}
t.sendBackupData(chatId, dbData, t.serverService.BackupFilename(""))
}
func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) {
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
t.SendMsgToTgbot(chatId, output)
// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
if dbData != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
document := tu.Document(
tu.ID(chatId),
tu.FileFromBytes(dbData, dbFilename),
)
_, err := bot.SendDocument(ctx, inAdminTopic(document))
cancel()
if err != nil {
logger.Error("Error in uploading backup: ", err)
}
}
// Small delay between file sends
time.Sleep(500 * time.Millisecond)
// Send config.json backup
file, err := os.Open(xray.GetConfigPath())
if err == nil {
defer file.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
document := tu.Document(
tu.ID(chatId),
tu.File(file),
)
_, err = bot.SendDocument(ctx, inAdminTopic(document))
if err != nil {
logger.Error("Error in uploading config.json: ", err)
}
} else {
logger.Error("Error in opening config.json file for backup: ", err)
}
}
// sendBanLogs sends the ban logs to the specified chat.
func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
if dt {
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
output += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
t.SendMsgToTgbot(chatId, output)
}
file, err := os.Open(xray.GetIPLimitBannedPrevLogPath())
if err == nil {
// Check if the file is non-empty before attempting to upload
fileInfo, _ := file.Stat()
if fileInfo.Size() > 0 {
document := tu.Document(
tu.ID(chatId),
tu.File(file),
)
_, err = bot.SendDocument(context.Background(), inAdminTopic(document))
if err != nil {
logger.Error("Error in uploading IPLimitBannedPrevLog: ", err)
}
} else {
logger.Warning("IPLimitBannedPrevLog file is empty, not uploading.")
}
file.Close()
} else {
logger.Error("Error in opening IPLimitBannedPrevLog file for backup: ", err)
}
file, err = os.Open(xray.GetIPLimitBannedLogPath())
if err == nil {
// Check if the file is non-empty before attempting to upload
fileInfo, _ := file.Stat()
if fileInfo.Size() > 0 {
document := tu.Document(
tu.ID(chatId),
tu.File(file),
)
_, err = bot.SendDocument(context.Background(), inAdminTopic(document))
if err != nil {
logger.Error("Error in uploading IPLimitBannedLog: ", err)
}
} else {
logger.Warning("IPLimitBannedLog file is empty, not uploading.")
}
file.Close()
} else {
logger.Error("Error in opening IPLimitBannedLog file for backup: ", err)
}
}
@@ -32,7 +32,9 @@ func seedReportClients(t *testing.T, remark string, emails []string) {
settings = append(settings, fmt.Sprintf(`{"email":%q,"subId":"sub-%s"}`, email, email))
}
inbound := &model.Inbound{
UserId: 1,
UserId: 1,
// The tag is unique per inbound, so a test may seed more than one.
Tag: "tag-" + remark,
Remark: remark,
Port: 8443,
Protocol: model.VLESS,
@@ -74,11 +76,20 @@ func initReportDB(t *testing.T) *Tgbot {
type sentMessage struct {
Text string `json:"text"`
Caption string `json:"caption"`
ReplyMarkup json.RawMessage `json:"reply_markup"`
}
// captureReportServer records every sendMessage call so a test can assert on
// what Telegram would have received, not merely how many calls were made.
// body is what Telegram would show, whichever call carried it.
func (m sentMessage) body() string {
if m.Text != "" {
return m.Text
}
return m.Caption
}
// captureReportServer records every message-shaped call (sendMessage and the screen's sendPhoto), so a
// test asserts on what Telegram would receive, not just how many calls were made.
func captureReportServer(t *testing.T) (*httptest.Server, func() []sentMessage) {
t.Helper()
var mu sync.Mutex
@@ -86,7 +97,8 @@ func captureReportServer(t *testing.T) (*httptest.Server, func() []sentMessage)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
result := any(true)
if r.URL.Path == "/bot"+testBotToken+"/sendMessage" {
path := r.URL.Path
if path == "/bot"+testBotToken+"/sendMessage" || path == "/bot"+testBotToken+"/sendPhoto" {
var payload sentMessage
_ = json.Unmarshal(body, &payload)
mu.Lock()
@@ -107,14 +119,7 @@ func captureReportServer(t *testing.T) (*httptest.Server, func() []sentMessage)
// Regression test: the sorted usage report must reach Telegram as one message
// whatever the client count; per-client sends burst past the rate limit.
func TestTrafficUsageReportIsOneMessage(t *testing.T) {
mock, calls := staleButtonServer(t, map[string]any{
"sendMessage": map[string]any{"ok": true, "result": map[string]any{
"message_id": 1,
"date": 0,
"chat": map[string]any{"id": 1, "type": "private"},
}},
"deleteMessage": map[string]any{"ok": true, "result": true},
})
mock, sent := captureReportServer(t)
swapTestBot(t, mock.URL)
defer mock.Close()
@@ -128,8 +133,8 @@ func TestTrafficUsageReportIsOneMessage(t *testing.T) {
Message: &telego.Message{Chat: telego.Chat{ID: 1}},
}, true) // admin
if n := calls("sendMessage"); n != 1 {
t.Errorf("sendMessage calls = %d, want 1: one report per tap, not one per client", n)
if n := len(sent()); n != 1 {
t.Errorf("messages = %d, want 1: one report per tap, not one per client", n)
}
}
@@ -150,14 +155,18 @@ func TestResetAllTrafficsAnswersWithNoClients(t *testing.T) {
}, true) // admin
got := sent()
if len(got) != 1 {
t.Fatalf("sendMessage calls = %d, want 1: an empty panel must still answer the tap", len(got))
// Two messages: the report and the menu that replaces the confirmation, which used to stay on screen
// with only the button just pressed.
if len(got) != 2 {
t.Fatalf("messages = %d, want 2: the report and the menu that replaces the confirmation", len(got))
}
if got[0].Text == "" {
if got[0].body() == "" {
t.Error("reset report text is empty, want the finish-process message")
}
if !strings.Contains(string(got[0].ReplyMarkup), `"remove_keyboard":true`) {
t.Errorf("reply_markup = %s, want the reply keyboard removed", got[0].ReplyMarkup)
// The bot keeps no reply keyboards any more: every answer carries the hide
// button instead, so the user can clear it with one tap.
if !strings.Contains(string(got[0].ReplyMarkup), `"hide"`) {
t.Errorf("reply_markup = %s, want the hide button", got[0].ReplyMarkup)
}
}
@@ -180,13 +189,10 @@ func TestTrafficUsageReportEscapesHtml(t *testing.T) {
got := sent()
if len(got) != 1 {
t.Fatalf("sendMessage calls = %d, want 1", len(got))
t.Fatalf("messages = %d, want 1", len(got))
}
if strings.Contains(got[0].Text, "<fast>") {
t.Errorf("report text = %q, want the remark escaped", got[0].Text)
}
if !strings.Contains(got[0].Text, "&lt;fast&gt;") {
t.Errorf("report text = %q, want the remark escaped as &lt;fast&gt;", got[0].Text)
if strings.Contains(got[0].body(), "<fast>") {
t.Errorf("report text = %q, want the remark escaped", got[0].body())
}
}
@@ -0,0 +1,274 @@
package tgbot
import (
"encoding/json"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mymmrac/telego"
)
// A login success must not fold into the failure card of the same IP: the
// sequence is the alert. The live-card key therefore carries the outcome.
func TestLoginSuccessAndFailureGetSeparateCards(t *testing.T) {
tb := &Tgbot{}
fail := eventbus.Event{
Type: eventbus.EventLoginAttempt,
Source: "10.0.0.1",
Data: &eventbus.LoginEventData{IP: "10.0.0.1", Username: "root", Status: "fail"},
}
ok := eventbus.Event{
Type: eventbus.EventLoginAttempt,
Source: "10.0.0.1",
Data: &eventbus.LoginEventData{IP: "10.0.0.1", Username: "root", Status: "success"},
}
if tb.eventNoticeKind(fail) == tb.eventNoticeKind(ok) {
t.Fatalf("a login success and a failure from the same IP share the key %q, so the "+
"failure card is overwritten", tb.eventNoticeKind(fail))
}
// Anything that is not a login keeps the old behaviour: one card per source.
out := eventbus.Event{Type: eventbus.EventOutboundDown, Source: "proxy-a"}
other := eventbus.Event{Type: eventbus.EventOutboundDown, Source: "proxy-b"}
if tb.eventNoticeKind(out) == tb.eventNoticeKind(other) {
t.Fatal("two different outbound sources share one card")
}
}
// The scheduled report posts its own message, so a cron landing on the
// add-client wizard cannot replace the draft the admin is editing.
func TestScheduledDepletionReportDoesNotEditTheLiveScreen(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
// A wizard the admin has open: the tracked screen of this chat.
tb.renderScreen(555, tb.newScreen("wizard", "draft in progress", tb.backRow()))
tb.sendReport(555, "🚨 Exhausted Clients count:\r\n🛑 Disabled: 0\r\n🔜 Deplete Soon: 0")
for _, c := range calls() {
if c.Method == "editMessageText" || c.Method == "editMessageMedia" {
t.Fatalf("the scheduled report edited the admin's live screen (%s)", c.Method)
}
}
tracked, ok := tb.screens().get(555)
if !ok || tracked.kind != "wizard" {
t.Fatalf("the wizard screen was replaced (tracked=%v ok=%v)", tracked, ok)
}
}
// Every message a report is split into carries its own dismiss button; the old
// behaviour left all but the last one undismissable.
func TestEveryReportMessageCanBeDismissed(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
tb.sendReport(777, reportTestBody())
sent := 0
for _, c := range calls() {
if c.Method != "sendMessage" {
continue
}
sent++
markup, _ := json.Marshal(c.Payload["reply_markup"])
if !strings.Contains(string(markup), "hide") {
t.Fatalf("report message %d offers no dismiss button: %s", sent, markup)
}
}
if sent < 2 {
t.Fatalf("the report went out as %d message(s); the test needs a split body", sent)
}
}
// One report per run replaces the previous one instead of stacking a new copy
// every day.
func TestNewReportReplacesThePreviousOne(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
reportPrevSet(999, 0)
t.Cleanup(func() { reportPrevSet(999, 0) })
tb.sendReport(999, "first report")
firstID, ok := reportPrevGet(999)
if !ok || firstID == 0 {
t.Fatalf("the first report was not tracked (id=%d ok=%v)", firstID, ok)
}
tb.sendReport(999, "second report")
deleted := false
for _, c := range calls() {
if c.Method != "deleteMessage" {
continue
}
if id, ok := c.Payload["message_id"].(float64); ok && int(id) == firstID {
deleted = true
}
}
if !deleted {
t.Fatalf("the previous report (message %d) was not removed", firstID)
}
if prev, _ := reportPrevGet(999); prev == firstID {
t.Fatal("the tracked report id still points at the deleted message")
}
}
// A client's daily alert about its own traffic is routine too: it must carry the
// same dismiss the rest of the bot uses, not arrive as an unclearable message.
func TestClientTrafficAlertIsDismissable(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
tb.sendNotice(4242, "🚨 Your traffic is running out")
for _, c := range calls() {
if c.Method != "sendMessage" {
continue
}
markup, _ := json.Marshal(c.Payload["reply_markup"])
if !strings.Contains(string(markup), "hide") {
t.Fatalf("the client alert carries no dismiss button: %s", markup)
}
return
}
t.Fatal("no alert message was sent")
}
// reportTestBody is long enough that pageMessage splits it into several messages.
func reportTestBody() string {
return strings.Repeat("📈 line of the scheduled report\r\n", 260)
}
// The server screen carries the host's addresses and versions, so /status must
// not hand it to a client-level account.
func TestStatusCommandGivesAClientLivenessOnly(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
setTestAdmins(t, 1001)
tb.answerCommand(&telego.Message{
Chat: telego.Chat{ID: 2002, Type: "private"},
From: &telego.User{ID: 2002},
Text: "/status",
}, 2002, false)
if methods := methods(calls()); len(methods) == 0 {
t.Fatal("a client got no answer to /status")
}
for _, c := range calls() {
text := c.text()
if strings.Contains(text, "Xray") || strings.Contains(text, "Uptime") {
t.Fatalf("a client received the server screen: %q", text)
}
}
}
// The inline browser listed every inbound to any client-level account, which
// leaked remarks, ports and client counts.
func TestInlineBrowserRefusesInboundsForAClient(t *testing.T) {
tb := &Tgbot{}
for _, action := range []string{"", "inb", "add"} {
if got := tb.inlineResults(action, "", "", false); len(got) != 0 {
t.Fatalf("client-level inline scope %q returned %d results", action, len(got))
}
}
}
// Past ~80 clients the per-client reset report exceeded Telegram's message limit
// whole, so the admin saw no outcome at all.
func TestResetAllReportIsPaged(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
// Roughly what 120 clients produce.
body := strings.Repeat("✅ Traffic reset for client@example.com\r\n\r\n", 120)
tb.sendPagedNotice(31337, body)
sent := 0
for _, c := range calls() {
if c.Method != "sendMessage" {
continue
}
sent++
if len(c.text()) > telegramPageLimit {
t.Fatalf("message %d is %d bytes, over the %d limit", sent, len(c.text()), telegramPageLimit)
}
}
if sent < 2 {
t.Fatalf("the report went out as %d message(s); it must be split", sent)
}
}
// A single line longer than a caption used to become a page over the limit, and
// Telegram rejected it whole: the screen never appeared.
func TestOverlongLineIsSplitInsteadOfRejected(t *testing.T) {
long := "vless://" + strings.Repeat("a", 4000)
for i, page := range splitMessageLines(long, botCaptionLimit) {
if len(page) > botCaptionLimit {
t.Fatalf("page %d is %d bytes, over the %d caption limit", i, len(page), botCaptionLimit)
}
}
if joined := strings.Join(splitMessageLines(long, botCaptionLimit), ""); joined != long {
t.Fatal("splitting an over-long line lost or changed bytes")
}
}
// /usage with no argument is how a client asks about its own traffic; the rows
// were built from email[0] and panicked on the empty slice.
func TestUsageWithoutAnArgumentDoesNotPanic(t *testing.T) {
initReportDB(t)
tb, _ := newScreenTgbot(t, false)
seedClientForTgUser(t, 3131, "usage-client@example.com")
defer func() {
if r := recover(); r != nil {
t.Fatalf("getClientUsage panicked: %v", r)
}
}()
tb.getClientUsage(3131, 3131)
if sc, ok := tb.screens().get(3131); !ok || sc.msgID == 0 {
t.Fatalf("the usage path never rendered a screen (ok=%v): the test does not exercise the row-building line", ok)
}
}
// The usage card drew the admin keyboard for every caller, offering a client reset-traffic, IP-limit and
// enable buttons that act on an account.
func TestUsageGivesAClientNoAdminButtons(t *testing.T) {
initReportDB(t)
tb, _ := newScreenTgbot(t, false)
seedClientForTgUser(t, 4141, "usage-self@example.com")
tb.getClientUsage(4141, 4141)
sc, ok := tb.screens().get(4141)
if !ok {
t.Fatal("no screen was tracked")
}
markup, _ := json.Marshal(sc.markup)
for _, action := range []string{
"reset_traffic", "limit_traffic", "ip_limit", "ip_log",
"toggle_enable", "tg_user", "reset_exp", "client_invite_link",
} {
if strings.Contains(string(markup), action) {
t.Fatalf("a client's usage card offered the admin control %q: %s", action, markup)
}
}
}
// A non-admin email argument must not open an admin card either: /usage <email>
// for a client resolves to its own card, never to the named one.
func TestUsageEmailArgumentDoesNotLeakAnotherClient(t *testing.T) {
initReportDB(t)
tb, _ := newScreenTgbot(t, false)
seedClientForTgUser(t, 5151, "mine@example.com")
seedClientForTgUser(t, 9999, "other@example.com")
tb.getClientUsage(5151, 5151, "other@example.com")
sc, ok := tb.screens().get(5151)
if !ok {
t.Fatal("no screen was tracked")
}
body := sc.body + sc.text
markup, _ := json.Marshal(sc.markup)
if strings.Contains(body, "other@example.com") || strings.Contains(string(markup), "other@example.com") {
t.Fatalf("a client's /usage <email> leaked another client:\n%s\n%s", body, markup)
}
}
File diff suppressed because it is too large Load Diff
+37 -111
View File
@@ -12,71 +12,6 @@ import (
tu "github.com/mymmrac/telego/telegoutil"
)
// sendResponse sends the response message based on the onlyMessage flag.
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
if onlyMessage {
t.SendMsgToTgbot(chatId, msg)
} else {
t.SendAnswer(chatId, msg, isAdmin)
}
}
// SendAnswer sends a response message with an inline keyboard to the specified chat.
func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
numericKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.SortedTrafficUsageReport")).WithCallbackData(t.encodeQuery("get_sorted_traffic_usage_report")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.serverUsage")).WithCallbackData(t.encodeQuery("get_usage")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ResetAllTraffics")).WithCallbackData(t.encodeQuery("reset_all_traffics")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.dbBackup")).WithCallbackData(t.encodeQuery("get_backup")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getBanLogs")).WithCallbackData(t.encodeQuery("get_banlogs")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getInbounds")).WithCallbackData(t.encodeQuery("inbounds")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.depleteSoon")).WithCallbackData(t.encodeQuery("deplete_soon")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("commands")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.onlines")).WithCallbackData(t.encodeQuery("onlines")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("admin_client_sub_links")),
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("admin_client_individual_links")),
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("admin_client_qr_links")),
),
// TODOOOOOOOOOOOOOO: Add restart button here.
)
numericKeyboardClient := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("client_sub_links")),
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links")),
),
)
var ReplyMarkup telego.ReplyMarkup
if isAdmin {
ReplyMarkup = numericKeyboard
} else {
ReplyMarkup = numericKeyboardClient
}
t.SendMsgToTgbot(chatId, msg, ReplyMarkup)
}
const telegramPageLimit = 2000
func pageMessage(message string, limit int) []string {
@@ -107,8 +42,18 @@ func splitMessageLines(block string, limit int) []string {
}
lines := strings.Split(block, "\r\n")
pages := []string{lines[0]}
for _, line := range lines[1:] {
pages := []string{}
for _, line := range lines {
// A single link can exceed a caption on its own; Telegram counts bytes,
// so such a line is split rather than kept whole and rejected.
for len(line) > limit {
pages = append(pages, line[:limit])
line = line[limit:]
}
if len(pages) == 0 {
pages = append(pages, line)
continue
}
last := len(pages) - 1
if len(pages[last])+len("\r\n")+len(line) > limit {
pages = append(pages, line)
@@ -116,19 +61,24 @@ func splitMessageLines(block string, limit int) []string {
}
pages[last] += "\r\n" + line
}
if len(pages) == 0 {
pages = append(pages, "")
}
return pages
}
// SendMsgToTgbot sends a message to the Telegram bot with optional reply markup.
func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) {
// SendMsgToTgbot sends a message with optional reply markup and returns the id
// of the last one delivered, or 0; only the broadcast prompt needs it.
func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) int {
if !t.IsRunning() {
return
return 0
}
if msg == "" {
logger.Info("[tgbot] message is empty!")
return
return 0
}
lastID := 0
allMessages := pageMessage(msg, telegramPageLimit)
for n, message := range allMessages {
@@ -147,10 +97,13 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
maxRetries := 3
for attempt := range maxRetries {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
_, err := bot.SendMessage(ctx, &params)
sent, err := liveBot().SendMessage(ctx, &params)
cancel()
if err == nil {
if sent != nil {
lastID = sent.MessageID
}
break // Success
}
@@ -177,6 +130,7 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
time.Sleep(100 * time.Millisecond)
}
}
return lastID
}
// SendMsgToTgbotAdmins sends a message to all admin Telegram chats.
@@ -199,7 +153,7 @@ func (t *Tgbot) sendCallbackAnswerTgBot(id string, message string) {
CallbackQueryID: id,
Text: message,
}
if err := bot.AnswerCallbackQuery(context.Background(), &params); err != nil {
if err := liveBot().AnswerCallbackQuery(context.Background(), &params); err != nil {
logger.Warning(err)
}
}
@@ -211,7 +165,7 @@ func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyb
MessageID: messageID,
ReplyMarkup: inlineKeyboard,
}
if _, err := bot.EditMessageReplyMarkup(context.Background(), &params); err != nil {
if _, err := liveBot().EditMessageReplyMarkup(context.Background(), &params); err != nil {
if isTelegramNotModifiedError(err) {
logger.Debug("Telegram reply markup unchanged, skipping edit")
return
@@ -231,7 +185,7 @@ func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlin
if len(inlineKeyboard) > 0 {
params.ReplyMarkup = inlineKeyboard[0]
}
if _, err := bot.EditMessageText(context.Background(), &params); err != nil {
if _, err := liveBot().EditMessageText(context.Background(), &params); err != nil {
if isTelegramNotModifiedError(err) {
logger.Debug("Telegram message text unchanged, skipping edit")
return
@@ -251,50 +205,24 @@ func isTelegramNotModifiedError(err error) bool {
strings.Contains(errStr, "No fields to modify")
}
// SendMsgToTgbotDeleteAfter sends a message and deletes it after a specified delay.
func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSeconds int, replyMarkup ...telego.ReplyMarkup) {
// Determine if replyMarkup was passed; otherwise, set it to nil
var replyMarkupParam telego.ReplyMarkup
if len(replyMarkup) > 0 {
replyMarkupParam = replyMarkup[0] // Use the first element
// sendPagedNotice posts one notice per page, each dismissable. Telegram rejects
// a message over 4096 characters whole, so a per-client report has to be split.
func (t *Tgbot) sendPagedNotice(chatID int64, text string) {
for _, chunk := range pageMessage(text, telegramPageLimit) {
t.sendNoticeReturningID(chatID, chunk)
}
// Send the message
sentMsg, err := bot.SendMessage(context.Background(), &telego.SendMessageParams{
ChatID: tu.ID(chatId),
MessageThreadID: adminTopic(chatId),
Text: msg,
ReplyMarkup: replyMarkupParam, // Use the correct replyMarkup value
})
if err != nil {
logger.Warning("Failed to send message:", err)
return
}
// Delete the sent message after the specified number of seconds.
go t.deleteMessageAfterDelay(chatId, sentMsg.MessageID, delayInSeconds)
}
// deleteMessageAfterDelay waits delayInSeconds and then removes the message. It
// deliberately does not touch the conversation state: every caller that ends a
// wizard step already clears the state synchronously, and clearing it here — up
// to several seconds later — would wipe a state the user set for the next step
// in the meantime, silently dropping their following input.
func (t *Tgbot) deleteMessageAfterDelay(chatId int64, messageID, delayInSeconds int) {
time.Sleep(time.Duration(delayInSeconds) * time.Second)
t.deleteMessageTgBot(chatId, messageID)
}
// deleteMessageTgBot deletes a message from the chat.
func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
if bot == nil {
if liveBot() == nil {
return
}
params := telego.DeleteMessageParams{
ChatID: tu.ID(chatId),
MessageID: messageID,
}
if err := bot.DeleteMessage(context.Background(), &params); err != nil {
if err := liveBot().DeleteMessage(context.Background(), &params); err != nil {
logger.Warning("Failed to delete message:", err)
} else {
logger.Info("Message deleted successfully")
@@ -303,9 +231,7 @@ func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
// TestConnection verifies the bot token is valid and the API is reachable.
func (t *Tgbot) TestConnection() error {
tgBotMutex.Lock()
b := bot
tgBotMutex.Unlock()
b := liveBot()
if b == nil {
return fmt.Errorf("bot not initialized")
}
@@ -53,8 +53,8 @@ func TestEditMessageTgBotSkipsNotModified(t *testing.T) {
defer mock.Close()
// Point the package-level bot at the mock.
origBot := bot
t.Cleanup(func() { bot = origBot })
origBot := liveBot()
t.Cleanup(func() { setTestBot(origBot) })
var err error
bot, err = telego.NewBot(testBotToken, telego.WithAPIServer(mock.URL))
if err != nil {
@@ -85,8 +85,8 @@ func TestEditMessageCallbackTgBotSkipsNotModified(t *testing.T) {
}))
defer mock.Close()
origBot := bot
t.Cleanup(func() { bot = origBot })
origBot := liveBot()
t.Cleanup(func() { setTestBot(origBot) })
var err error
bot, err = telego.NewBot(testBotToken, telego.WithAPIServer(mock.URL))
if err != nil {
@@ -40,20 +40,32 @@ func staleButtonServer(t *testing.T, responses map[string]any) (*httptest.Server
}
}
// setTestBot swaps the process-wide connection the way EnsureBot does: under the
// mutex, so the race detector sees a well-defined handover.
func setTestBot(b *telego.Bot) {
botMu.Lock()
bot = b
botMu.Unlock()
}
func swapTestBot(t *testing.T, url string) {
t.Helper()
origBot := bot
origBot := liveBot()
origPool := messageWorkerPool
t.Cleanup(func() {
bot = origBot
setTestBot(origBot)
messageWorkerPool = origPool
})
var err error
bot, err = telego.NewBot(testBotToken, telego.WithAPIServer(url))
newBot, err := telego.NewBot(testBotToken, telego.WithAPIServer(url))
if err != nil {
t.Fatalf("NewBot: %v", err)
}
setTestBot(newBot)
messageWorkerPool = make(chan struct{}, 10)
// The screen store is process-wide state: a screen left by an earlier test
// would make this one edit an unrelated message instead of sending.
screens.reset()
resetScreenArt()
}
func newStaleButtonTgbot(t *testing.T) *Tgbot {