Files
MeBox/internal/service/telegram_api_test.go
T

503 lines
17 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/ShukeBta/MediaStationGo/internal/model"
"go.uber.org/zap"
)
func TestTelegramMethodURLUsesCustomAPIBase(t *testing.T) {
got, err := telegramMethodURL(map[string]string{
"api_base_url": "https://tg.example.com/",
}, "123456:ABC-def", "sendMessage")
if err != nil {
t.Fatalf("telegramMethodURL returned error: %v", err)
}
want := "https://tg.example.com/bot123456:ABC-def/sendMessage"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestSanitizeTelegramErrorRedactsBotToken(t *testing.T) {
err := sanitizeTelegramError(errors.New(`Post "https://api.telegram.org/bot123456:SECRET/sendMessage": context deadline exceeded`))
if err == nil {
t.Fatal("expected error")
}
msg := err.Error()
if strings.Contains(msg, "SECRET") || strings.Contains(msg, "123456:") {
t.Fatalf("telegram token leaked in error: %s", msg)
}
if !strings.Contains(msg, "timeout") {
t.Fatalf("expected timeout hint, got: %s", msg)
}
}
func TestValidateTelegramChannelDoesNotRequireLegacyChatID(t *testing.T) {
err := validateChannel(ChannelInput{
Name: "Telegram",
Type: "telegram",
Config: map[string]any{
"bot_token": "123456:ABC-def",
"admin_user_ids": "10001",
},
})
if err != nil {
t.Fatalf("validateChannel returned error: %v", err)
}
}
func TestTelegramTargetChatIDsFallsBackToAdmins(t *testing.T) {
got := telegramTargetChatIDs(map[string]string{
"admin_user_ids": "10001, 10002",
})
if len(got) != 2 || got[0] != "10001" || got[1] != "10002" {
t.Fatalf("got %#v, want admin user ids", got)
}
}
func TestNormalizeTelegramChannelMigratesLegacyChatID(t *testing.T) {
input := ChannelInput{
Name: "Telegram",
Type: "telegram",
Config: map[string]any{
"chat_id": "-10001",
},
}
normalizeChannelInput(&input)
if got := str(input.Config["group_chat_id"]); got != "-10001" {
t.Fatalf("group_chat_id = %q, want -10001", got)
}
}
func TestNormalizeTelegramChannelMigratesLegacyPrivateChatIDToAdmin(t *testing.T) {
cfg := map[string]string{"chat_id": "5812333517"}
normalizeTelegramConfig(cfg)
if got := cfg["admin_user_ids"]; got != "5812333517" {
t.Fatalf("admin_user_ids = %q, want legacy chat_id", got)
}
}
func TestTelegramTargetChatIDsUsesLegacyPrivateChatID(t *testing.T) {
got := telegramTargetChatIDs(map[string]string{
"chat_id": "5812333517",
})
if len(got) != 1 || got[0] != "5812333517" {
t.Fatalf("got %#v, want legacy private chat target", got)
}
}
func TestRegisterTelegramBotCommands(t *testing.T) {
var gotPath string
var payloads []struct {
Commands []telegramBotCommand `json:"commands"`
Scope map[string]any `json:"scope"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
var payload struct {
Commands []telegramBotCommand `json:"commands"`
Scope map[string]any `json:"scope"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
payloads = append(payloads, payload)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
err := registerTelegramBotCommands(t.Context(), map[string]string{
"bot_token": "123456:ABC",
"api_base_url": server.URL,
})
if err != nil {
t.Fatal(err)
}
if gotPath != "/bot123456:ABC/setMyCommands" {
t.Fatalf("path = %q", gotPath)
}
if len(payloads) < 3 {
t.Fatalf("expected default/private/group command registrations, got %d", len(payloads))
}
if len(payloads[0].Commands) == 0 || payloads[0].Commands[0].Command != "start" {
t.Fatalf("commands not registered: %#v", payloads[0].Commands)
}
var groupCommands []telegramBotCommand
for _, payload := range payloads {
if payload.Scope["type"] == "all_group_chats" {
groupCommands = payload.Commands
break
}
}
if len(groupCommands) == 0 {
t.Fatal("group command scope was not registered")
}
for _, command := range groupCommands {
if command.Command == "users" || command.Command == "status" || command.Command == "cleanup" || command.Command == "register" || command.Command == "redeem" {
t.Fatalf("group commands must not expose private/admin command %q", command.Command)
}
}
}
func TestDeleteTelegramWebhookBeforePolling(t *testing.T) {
var gotPath string
var payload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
err := deleteTelegramWebhook(t.Context(), map[string]string{
"bot_token": "123456:ABC",
"api_base_url": server.URL,
})
if err != nil {
t.Fatal(err)
}
if gotPath != "/bot123456:ABC/deleteWebhook" {
t.Fatalf("path = %q", gotPath)
}
if got := payload["drop_pending_updates"]; got != false {
t.Fatalf("drop_pending_updates = %#v, want false", got)
}
}
func TestTelegramCommandMenusSeparateGroupAndAdminCommands(t *testing.T) {
privateNames := telegramCommandNames(telegramPrivateBotCommandMenu())
for _, required := range []string{"setname", "setpass"} {
if !privateNames[required] {
t.Fatalf("private menu should include %s", required)
}
}
for _, hiddenAlias := range []string{"myinfo", "count"} {
if privateNames[hiddenAlias] {
t.Fatalf("private menu should hide compatibility alias %s", hiddenAlias)
}
if !telegramSupportedCommand("/" + hiddenAlias) {
t.Fatalf("compatibility alias /%s should remain executable", hiddenAlias)
}
}
groupNames := telegramCommandNames(telegramGroupBotCommandMenu())
for _, forbidden := range []string{"status", "search", "downloads", "stats", "users", "cleanup", "cleanup_rule", "register", "redeem"} {
if groupNames[forbidden] {
t.Fatalf("group menu should not expose %s", forbidden)
}
}
for _, required := range []string{"start", "menu", "help", "account", "signin", "devices", "kick", "hideadult"} {
if !groupNames[required] {
t.Fatalf("group menu should include %s", required)
}
}
adminCommands := telegramAdminBotCommandMenu()
adminNames := telegramCommandNames(adminCommands)
for _, required := range []string{"users", "status", "cleanup_mode", "cleanup_rule", "ucr", "uinfo", "rmemby", "only_rm_record", "renewall", "userip", "auditip", "auditdevice", "auditclient", "udeviceid", "syncunbound", "syncgroupm", "check_ex", "deleted", "embyadmin", "banall", "unbanall", "prouser", "revuser", "embylibs_blockall", "embylibs_unblockall", "proadmin", "revadmin", "backup_db", "restore_from_db"} {
if !adminNames[required] {
t.Fatalf("admin menu should include %s", required)
}
}
for _, hiddenAlias := range []string{"myinfo", "count", "low_activity", "urm", "only_rm_emby", "extraembylibs_blockall", "extraembylibs_unblockall"} {
if adminNames[hiddenAlias] {
t.Fatalf("admin menu should hide compatibility alias %s", hiddenAlias)
}
}
for _, command := range adminCommands {
if strings.Contains(command.Description, "Mgo 兼容") {
t.Fatalf("admin menu command %s should use native Mgo wording: %q", command.Command, command.Description)
}
}
help := telegramMgoAdminCommandHelp()
for _, want := range []string{"用户:", "审计:", "清理:", "权限:", "运维:"} {
if !strings.Contains(help, want) {
t.Fatalf("mgo admin help should include category %q in %q", want, help)
}
}
if strings.Contains(help, "/setpass") {
t.Fatalf("mgo admin help should not include user self-service command /setpass")
}
}
func telegramCommandNames(commands []telegramBotCommand) map[string]bool {
names := make(map[string]bool, len(commands))
for _, command := range commands {
names[command.Command] = true
}
return names
}
func TestTelegramProxyCandidatesDefaultLocalFallbacks(t *testing.T) {
got := telegramProxyCandidates(map[string]string{})
joined := strings.Join(got, ",")
for _, want := range []string{"127.0.0.1:10808", "127.0.0.1:7890", "host.docker.internal:7890", "172.17.0.1:7890"} {
if !strings.Contains(joined, want) {
t.Fatalf("default proxy candidates %q missing %q", joined, want)
}
}
}
func TestTelegramHTTPClientsPreferConfiguredProxy(t *testing.T) {
clients := telegramHTTPClients(time.Second, map[string]string{
"proxy_url": "http://proxy.example:7890",
})
if len(clients) == 0 {
t.Fatal("expected telegram clients")
}
if got := telegramClientProxyString(t, clients[0]); got != "http://proxy.example:7890" {
t.Fatalf("first client proxy = %q, want configured proxy", got)
}
}
func telegramClientProxyString(t *testing.T, client *http.Client) string {
t.Helper()
transport, ok := client.Transport.(*http.Transport)
if !ok || transport.Proxy == nil {
return ""
}
req, err := http.NewRequest(http.MethodGet, defaultTelegramAPIBaseURL, nil)
if err != nil {
t.Fatal(err)
}
proxyURL, err := transport.Proxy(req)
if err != nil {
t.Fatal(err)
}
if proxyURL == nil {
return ""
}
return proxyURL.String()
}
func TestTelegramReplyAutoDeletesSentMessage(t *testing.T) {
requests := make(chan string, 4)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/sendMessage"):
requests <- "sendMessage"
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":777}}`))
case strings.HasSuffix(r.URL.Path, "/deleteMessage"):
requests <- "deleteMessage"
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":true}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
cfg, _ := json.Marshal(map[string]string{
"bot_token": "123456:ABC-def",
"api_base_url": server.URL,
"auto_delete_seconds": "0",
})
_, bot := newBotTestService(t)
channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}
if err := bot.reply(context.Background(), channel, 42, telegramCommandReply{Text: "hello"}); err != nil {
t.Fatalf("reply: %v", err)
}
waitForTelegramMethod(t, requests, "sendMessage")
waitForTelegramMethod(t, requests, "deleteMessage")
}
func TestTelegramGroupCommandSendsPanelInGroup(t *testing.T) {
var payloads []struct {
ChatID any `json:"chat_id"`
Text string `json:"text"`
ReplyMarkup map[string]any `json:"reply_markup"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/sendMessage") {
http.NotFound(w, r)
return
}
var payload struct {
ChatID any `json:"chat_id"`
Text string `json:"text"`
ReplyMarkup map[string]any `json:"reply_markup"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode sendMessage: %v", err)
}
payloads = append(payloads, payload)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":777}}`))
}))
defer server.Close()
repos, bot := newBotTestService(t)
cfg, _ := json.Marshal(map[string]string{
"bot_token": "123456:ABC-def",
"api_base_url": server.URL,
"group_chat_id": "-100123",
"auto_delete_seconds": "-1",
})
if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}).Error; err != nil {
t.Fatalf("create channel: %v", err)
}
update, _ := json.Marshal(TelegramUpdate{
UpdateID: 1,
Message: &TelegramMessage{
MessageID: 55,
From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"},
Chat: TelegramChat{ID: -100123, Type: "supergroup"},
Text: "/menu",
},
})
if err := bot.HandleWebhook(t.Context(), update); err != nil {
t.Fatalf("handle webhook: %v", err)
}
if len(payloads) != 1 {
t.Fatalf("sendMessage count = %d, payloads=%#v", len(payloads), payloads)
}
if got := fmt.Sprint(payloads[0].ChatID); got != "-100123" {
t.Fatalf("message should stay in group, chat_id=%s payload=%#v", got, payloads[0])
}
if strings.Contains(payloads[0].Text, "管理员入口") {
t.Fatalf("normal group user must not see admin panel: %#v", payloads[0])
}
}
func TestTelegramGroupCallbackIsRejected(t *testing.T) {
var callbackPayloads []struct {
CallbackID string `json:"callback_query_id"`
Text string `json:"text"`
ShowAlert bool `json:"show_alert"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/answerCallbackQuery") {
http.NotFound(w, r)
return
}
var payload struct {
CallbackID string `json:"callback_query_id"`
Text string `json:"text"`
ShowAlert bool `json:"show_alert"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode answerCallbackQuery: %v", err)
}
callbackPayloads = append(callbackPayloads, payload)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":true}`))
}))
defer server.Close()
ctx := t.Context()
repos, auth, _, _ := newAuthTestServices(t)
user, _, err := auth.Register(ctx, "viewer", "secret-pass")
if err != nil {
t.Fatalf("register user: %v", err)
}
if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil {
t.Fatalf("migrate notify channel: %v", err)
}
if err := repos.DB.Create(&model.TelegramBinding{
TelegramUserID: 9002,
TelegramName: "@viewer",
ChatID: 9002,
UserID: user.ID,
}).Error; err != nil {
t.Fatalf("create binding: %v", err)
}
cfg, _ := json.Marshal(map[string]string{
"bot_token": "123456:ABC-def",
"api_base_url": server.URL,
"group_chat_id": "-100123",
"auto_delete_seconds": "-1",
})
if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}).Error; err != nil {
t.Fatalf("create channel: %v", err)
}
before, _ := repos.User.FindByID(ctx, user.ID)
bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
update, _ := json.Marshal(TelegramUpdate{
UpdateID: 2,
CallbackQuery: &TelegramCallbackQuery{
ID: "cb-group",
From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"},
Message: &TelegramMessage{MessageID: 56, Chat: TelegramChat{ID: -100123, Type: "supergroup"}},
Data: "adult_toggle",
},
})
if err := bot.HandleWebhook(ctx, update); err != nil {
t.Fatalf("handle webhook: %v", err)
}
if len(callbackPayloads) != 1 {
t.Fatalf("answerCallbackQuery count = %d", len(callbackPayloads))
}
if !callbackPayloads[0].ShowAlert || !strings.Contains(callbackPayloads[0].Text, "群组内按钮面板已禁用") {
t.Fatalf("unexpected callback answer: %#v", callbackPayloads[0])
}
after, _ := repos.User.FindByID(ctx, user.ID)
if before == nil || after == nil || before.HideAdult != after.HideAdult {
t.Fatalf("group callback should not mutate user adult visibility: before=%#v after=%#v", before, after)
}
}
func waitForTelegramMethod(t *testing.T, requests <-chan string, want string) {
t.Helper()
deadline := time.After(2 * time.Second)
for {
select {
case got := <-requests:
if got == want {
return
}
case <-deadline:
t.Fatalf("timed out waiting for telegram %s", want)
}
}
}
func TestTelegramCommandFiltering(t *testing.T) {
if telegramIsCommandText("今天看什么") {
t.Fatal("plain chat message should not be treated as command")
}
if !telegramIsCommandText("/start user pass") {
t.Fatal("/start should be treated as command")
}
if got := telegramCommandName("/hideadult@MediaStationGoBot on"); got != "/hideadult" {
t.Fatalf("telegramCommandName = %q, want /hideadult", got)
}
if telegramSupportedCommand("/签到") {
t.Fatal("unrelated group bot command should not be handled")
}
for _, cmd := range []string{"/signin", "/redeem", "/gencode", "/users", "/renew_user", "/delete_user", "/cleanup_rule"} {
if !telegramSupportedCommand(cmd) {
t.Fatalf("%s should be supported so group slash commands get feedback", cmd)
}
}
for _, cmd := range []string{"/restart", "/update_bot", "/coins", "/red", "/white_channel", "/config"} {
if telegramSupportedCommand(cmd) {
t.Fatalf("%s should not be treated as supported until it has a real Mgo implementation", cmd)
}
}
}
func TestTelegramSupportedCommandSetMatchesRegistry(t *testing.T) {
_, bot := newBotTestService(t)
channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`}
msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "admin"}, Chat: TelegramChat{ID: 9001, Type: "private"}}
for _, def := range bot.telegramCommandDefinitions(t.Context(), channel, msg) {
for _, alias := range def.Aliases {
if !telegramSupportedCommand(alias) {
t.Fatalf("registered command %s must be in telegramSupportedCommandSet", alias)
}
}
}
}