mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix subscription dedup progress tracking
This commit is contained in:
@@ -51,6 +51,7 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
|
|||||||
return nil, ErrMediaAlreadyInLibrary
|
return nil, ErrMediaAlreadyInLibrary
|
||||||
}
|
}
|
||||||
if existing, ok := d.findExistingDownloadTask(ctx, req); ok {
|
if existing, ok := d.findExistingDownloadTask(ctx, req); ok {
|
||||||
|
d.linkExistingDownloadTaskToSubscription(ctx, existing, req)
|
||||||
return existing, ErrDownloadAlreadyExists
|
return existing, ErrDownloadAlreadyExists
|
||||||
}
|
}
|
||||||
_ = d.ReloadConfig(ctx)
|
_ = d.ReloadConfig(ctx)
|
||||||
@@ -410,6 +411,46 @@ func torrentInDownloadRequestScope(torrent QBitTorrent, req downloadAddRequest)
|
|||||||
return sameOrChildPath(torrentSavePath, requestSavePath) || sameOrChildPath(requestSavePath, torrentSavePath)
|
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) {
|
func (d *DownloadService) createTask(ctx context.Context, userID, urlStr, savePath string, meta DownloadTaskMeta) (*model.DownloadTask, error) {
|
||||||
title := strings.TrimSpace(meta.Title)
|
title := strings.TrimSpace(meta.Title)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
|
|||||||
@@ -75,3 +75,82 @@ func TestEnqueueSiteSearchDedupMarksEnglishRangeAvailableForChineseSubscription(
|
|||||||
t.Fatalf("seen set missing candidate guid after dedup")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,18 +1,9 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTelegramMethodURLUsesCustomAPIBase(t *testing.T) {
|
func TestTelegramMethodURLUsesCustomAPIBase(t *testing.T) {
|
||||||
@@ -95,419 +86,3 @@ func TestTelegramTargetChatIDsUsesLegacyPrivateChatID(t *testing.T) {
|
|||||||
t.Fatalf("got %#v, want legacy private chat target", got)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user