Files
3x-ui-agent/internal/web/service/tgbot/tgbot_test.go
T
Muse ceca96571d P9(5): restore upstream Telegram bot (settings, backup, notifications)
Per user decision 2026-10-21 (reverses the 2026-10-20 drop-Telegram
call): port the upstream 3x-ui v3.7.0-generation Telegram bot wholesale
instead of rewriting a minimal set, adapted to this fork.

- new internal/web/service/tgbot package (telego): bot lifecycle,
  commands, reports, callback router, send helpers; token validation via
  telego; custom API server URL passes the fork's SSRF sanitizer
  (loopback/private bases fall back to the default endpoint, logged)
- settings: full upstream key set (tgBotEnable/Token/Proxy/APIServer/
  ChatId, tgRunTime, tgBotBackup, tgCpu/tgMemory, tgLang,
  tgEnabledEvents) with getters/setters; settings page gains a Telegram
  tab + sidebar entry; token shown masked, clearTgBotToken honored
- wiring (web.go): stats-notify cron registered from tgRunTime when the
  bot is enabled; CheckHashStorage job; CPU/memory alarm jobs gated on
  tgEnabledEvents + thresholds; login notifications via event bus;
  POST /panel/api/setting/testTgBot and /panel/api/backuptotgbot
- lite adaptation: StatsNotifyJob no longer gates on a local Xray core
  (this panel never runs one) so the periodic report and the scheduled
  DB backup (sendDocument) can actually fire
- translations: upstream TG keys restored across all locales

End-to-end smoke (real binary + fake Telegram API over HTTPS at a
public-looking address, browser form wire format): bot starts on
settings change (getMe), testTgBot delivers, panel restart re-arms the
bot, tgRunTime='@every 1m' cron really fires sendDocument carrying the
panel SQLite DB (magic verified) plus the report sendMessage, login
notification delivered, dead-endpoint failure is logged not silent.
13/13 PASS — see P9-REPORT.md.
2026-10-21 17:16:32 +00:00

126 lines
3.3 KiB
Go

package tgbot
import (
"io"
"net"
"reflect"
"testing"
"time"
)
func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
typ := reflect.TypeFor[LoginAttempt]()
if _, ok := typ.FieldByName("Password"); ok {
t.Fatal("LoginAttempt must not carry attempted passwords")
}
}
func TestIsSupportedBotProxyScheme(t *testing.T) {
supported := []string{
"socks5://127.0.0.1:1080",
"http://127.0.0.1:8080",
"https://127.0.0.1:8080",
}
for _, p := range supported {
if !isSupportedBotProxyScheme(p) {
t.Errorf("expected %q to be supported", p)
}
}
unsupported := []string{"", "ftp://x", "127.0.0.1:1080", "socks4://1.2.3.4:1080"}
for _, p := range unsupported {
if isSupportedBotProxyScheme(p) {
t.Errorf("expected %q to be unsupported", p)
}
}
}
func recordingDialTarget(t *testing.T, n int) (addr string, got chan []byte) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
got = make(chan []byte, 1)
t.Cleanup(func() { _ = ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
buf := make([]byte, n)
m, _ := io.ReadFull(conn, buf)
got <- buf[:m]
}()
return ln.Addr().String(), got
}
func TestTgbotProxyDialerSelectsHTTPForHTTPScheme(t *testing.T) {
addr, got := recordingDialTarget(t, len("CONNECT "))
tg := &Tgbot{}
client := tg.createRobustFastHTTPClient("http://" + addr)
if client.Dial == nil {
t.Fatal("Dial must be set for an http:// proxy")
}
go func() { _, _ = client.Dial("example.com:443") }()
select {
case b := <-got:
if string(b) != "CONNECT " {
t.Fatalf("expected HTTP CONNECT to the proxy, got %q", b)
}
case <-time.After(3 * time.Second):
t.Fatal("proxy never received a connection")
}
}
func TestTgbotProxyDialerSelectsSOCKSForSocks5Scheme(t *testing.T) {
addr, got := recordingDialTarget(t, 1)
tg := &Tgbot{}
client := tg.createRobustFastHTTPClient("socks5://" + addr)
if client.Dial == nil {
t.Fatal("Dial must be set for a socks5:// proxy")
}
go func() { _, _ = client.Dial("example.com:443") }()
select {
case b := <-got:
if len(b) != 1 || b[0] != 0x05 {
t.Fatalf("expected SOCKS5 greeting (0x05), got %v", b)
}
case <-time.After(3 * time.Second):
t.Fatal("proxy never received a connection")
}
}
func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
tg := &Tgbot{}
client := tg.createRobustFastHTTPClient("")
if client.Dial != nil {
t.Fatal("Dial must be nil when no proxy is configured")
}
}
func TestIsCommandForBotAllowsUntargetedCommand(t *testing.T) {
if !isCommandForBot("/status", "panel_bot") {
t.Fatal("untargeted commands must remain accepted")
}
}
func TestIsCommandForBotAllowsMatchingUsername(t *testing.T) {
if !isCommandForBot("/status@panel_bot", "Panel_Bot") {
t.Fatal("commands targeted to this bot must be accepted")
}
}
func TestIsCommandForBotRejectsOtherUsername(t *testing.T) {
if isCommandForBot("/status@other_bot", "panel_bot") {
t.Fatal("commands targeted to another bot must be ignored")
}
}
func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T) {
if !isCommandForBot("/status@panel_bot", "") {
t.Fatal("commands must remain accepted when the current bot username is unavailable")
}
}