diff --git a/internal/service/download_add.go b/internal/service/download_add.go index a2362ad..6081311 100644 --- a/internal/service/download_add.go +++ b/internal/service/download_add.go @@ -51,6 +51,7 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt return nil, ErrMediaAlreadyInLibrary } if existing, ok := d.findExistingDownloadTask(ctx, req); ok { + d.linkExistingDownloadTaskToSubscription(ctx, existing, req) return existing, ErrDownloadAlreadyExists } _ = d.ReloadConfig(ctx) @@ -410,6 +411,46 @@ func torrentInDownloadRequestScope(torrent QBitTorrent, req downloadAddRequest) return sameOrChildPath(torrentSavePath, requestSavePath) || sameOrChildPath(requestSavePath, torrentSavePath) } +func (d *DownloadService) linkExistingDownloadTaskToSubscription(ctx context.Context, task *model.DownloadTask, req downloadAddRequest) { + subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) + if d == nil || d.repo == nil || d.repo.DB == nil || task == nil || subscriptionID == "" || strings.TrimSpace(task.ID) == "" { + return + } + updates := map[string]any{} + if strings.TrimSpace(task.SubscriptionID) == "" { + updates["subscription_id"] = subscriptionID + task.SubscriptionID = subscriptionID + } + if strings.TrimSpace(task.MediaType) == "" && strings.TrimSpace(req.meta.MediaType) != "" { + updates["media_type"] = req.meta.MediaType + task.MediaType = req.meta.MediaType + } + if strings.TrimSpace(task.MediaCategory) == "" && strings.TrimSpace(req.meta.MediaCategory) != "" { + updates["media_category"] = req.meta.MediaCategory + task.MediaCategory = req.meta.MediaCategory + } + if strings.TrimSpace(task.PosterURL) == "" && strings.TrimSpace(req.meta.PosterURL) != "" { + updates["poster_url"] = req.meta.PosterURL + task.PosterURL = req.meta.PosterURL + } + if strings.TrimSpace(task.BackdropURL) == "" && strings.TrimSpace(req.meta.BackdropURL) != "" { + updates["backdrop_url"] = req.meta.BackdropURL + task.BackdropURL = req.meta.BackdropURL + } + if strings.TrimSpace(task.Overview) == "" && strings.TrimSpace(req.meta.Overview) != "" { + updates["overview"] = req.meta.Overview + task.Overview = req.meta.Overview + } + if !task.AllowExistingLibrary && req.meta.AllowExistingLibrary { + updates["allow_existing_library"] = true + task.AllowExistingLibrary = true + } + if len(updates) == 0 { + return + } + _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).Where("id = ?", task.ID).Updates(updates).Error +} + func (d *DownloadService) createTask(ctx context.Context, userID, urlStr, savePath string, meta DownloadTaskMeta) (*model.DownloadTask, error) { title := strings.TrimSpace(meta.Title) if title == "" { diff --git a/internal/service/subscription_site_search_enqueue_test.go b/internal/service/subscription_site_search_enqueue_test.go index 1a789c5..151a1af 100644 --- a/internal/service/subscription_site_search_enqueue_test.go +++ b/internal/service/subscription_site_search_enqueue_test.go @@ -75,3 +75,82 @@ func TestEnqueueSiteSearchDedupMarksEnglishRangeAvailableForChineseSubscription( t.Fatalf("seen set missing candidate guid after dedup") } } + +func TestEnqueueSiteSearchDedupLinksEnglishHistoryTaskToChineseSubscription(t *testing.T) { + db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) + repos := repository.New(db) + if err := repos.Download.Create(t.Context(), &model.DownloadTask{ + UserID: "u1", + Source: "qbittorrent", + URL: "https://pt.example/download?id=history", + Title: "The Heir 2026 S01E33-E34 2160p WEB-DL", + SavePath: "/downloads/国产剧", + Status: "queued", + Progress: 0.2, + }); err != nil { + t.Fatal(err) + } + + site := NewSiteService(zap.NewNop(), repos, "") + downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, site, NewHub(zap.NewNop())) + sub := &model.Subscription{ + Base: model.Base{ID: "sub-heir"}, + UserID: "u1", + Name: "家业 自动订阅", + Filter: "家业 2026", + MediaType: "tv", + MediaCategory: "国产剧", + SavePath: "/downloads", + TotalEpisodes: 42, + } + state := &siteSearchRunState{ + Keyword: "家业 2026", + SeenSet: map[string]struct{}{}, + Availability: LocalAvailability{ + TotalEpisodes: 42, + ExistingEpisodeKeys: map[string]struct{}{}, + MissingEpisodeKeys: map[string]struct{}{}, + }, + } + candidate := siteSearchCandidate{ + Item: SearchResult{ + Title: "The Heir 2026 S01E33-E34 2160p WEB-DL", + DownloadURL: "https://pt.example/download?id=new", + }, + Download: "https://pt.example/download?id=new", + GUID: "site|m-team|heir-33-34", + Season: 1, + Episode: 33, + Episodes: []int{33, 34}, + Pack: true, + } + + title, err := svc.enqueueSiteSearchCandidate(t.Context(), sub, candidate, state) + if err != nil { + t.Fatalf("enqueueSiteSearchCandidate returned %v, want dedup skip without error", err) + } + if title != "" { + t.Fatalf("title = %q, want empty because candidate was deduped", title) + } + var rows []model.DownloadTask + if err := db.Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 1 { + t.Fatalf("download rows = %d, want 1", len(rows)) + } + if rows[0].SubscriptionID != sub.ID { + t.Fatalf("history task subscription_id = %q, want %q", rows[0].SubscriptionID, sub.ID) + } + if rows[0].MediaType != "tv" || rows[0].MediaCategory != "国产剧" { + t.Fatalf("history task metadata = (%q, %q), want (tv, 国产剧)", rows[0].MediaType, rows[0].MediaCategory) + } + + availability := svc.pendingDownloadAvailability(t.Context(), sub) + for _, episode := range []int{33, 34} { + if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { + t.Fatalf("linked history task missing E%d availability: %#v", episode, availability.ExistingEpisodeKeys) + } + } +} diff --git a/internal/service/telegram_api_commands_test.go b/internal/service/telegram_api_commands_test.go new file mode 100644 index 0000000..302806a --- /dev/null +++ b/internal/service/telegram_api_commands_test.go @@ -0,0 +1,192 @@ +package service + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +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 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) + } + } + } +} diff --git a/internal/service/telegram_api_proxy_test.go b/internal/service/telegram_api_proxy_test.go new file mode 100644 index 0000000..f7ac75c --- /dev/null +++ b/internal/service/telegram_api_proxy_test.go @@ -0,0 +1,62 @@ +package service + +import ( + "net/http" + "strings" + "testing" + "time" +) + +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 TestTelegramHTTPClientsCustomAPIBaseSkipsDefaultProxyFallback(t *testing.T) { + clients := telegramHTTPClients(time.Second, map[string]string{ + "api_base_url": "http://127.0.0.1:18080", + }) + if len(clients) != 1 { + t.Fatalf("clients = %d, want direct client only", len(clients)) + } + if got := telegramClientProxyString(t, clients[0]); got != "" { + t.Fatalf("custom api_base_url proxy = %q, want direct", got) + } +} + +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() +} diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go index 6e10856..b6c944b 100644 --- a/internal/service/telegram_api_test.go +++ b/internal/service/telegram_api_test.go @@ -1,18 +1,9 @@ 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) { @@ -95,419 +86,3 @@ func TestTelegramTargetChatIDsUsesLegacyPrivateChatID(t *testing.T) { 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 TestTelegramHTTPClientsCustomAPIBaseSkipsDefaultProxyFallback(t *testing.T) { - clients := telegramHTTPClients(time.Second, map[string]string{ - "api_base_url": "http://127.0.0.1:18080", - }) - if len(clients) != 1 { - t.Fatalf("clients = %d, want direct client only", len(clients)) - } - if got := telegramClientProxyString(t, clients[0]); got != "" { - t.Fatalf("custom api_base_url proxy = %q, want direct", got) - } -} -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) - } - } - } -} diff --git a/internal/service/telegram_api_webhook_test.go b/internal/service/telegram_api_webhook_test.go new file mode 100644 index 0000000..81ca0a9 --- /dev/null +++ b/internal/service/telegram_api_webhook_test.go @@ -0,0 +1,197 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "go.uber.org/zap" +) + +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) + } + } +}