diff --git a/internal/service/download_add.go b/internal/service/download_add.go index 89166e6..8077200 100644 --- a/internal/service/download_add.go +++ b/internal/service/download_add.go @@ -56,7 +56,7 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt if !d.qb.IsConfigured() { return nil, errors.New("no default downloader configured") } - if d.torrentExistsByIdentity(ctx, req.title) { + if d.torrentExistsByIdentity(ctx, req) { task, err := d.createTask(ctx, userID, urlStr, req.savePath, req.meta) if err != nil { return nil, err @@ -250,13 +250,51 @@ func (d *DownloadService) findExistingDownloadTask(ctx context.Context, req down continue } current := downloadTaskIdentityKey(rows[i].Title) - if downloadTitleCoversRequest(rows[i].Title, req.title) || current == key { + if downloadTaskCoversAddRequest(rows[i].Title, req) || current == key { return &rows[i], true } } return nil, false } +func downloadTaskCoversAddRequest(existing string, req downloadAddRequest) bool { + if subscriptionRequestHasExplicitEpisodes(req) { + return downloadExplicitEpisodesCoverRequest(existing, req.title) + } + return downloadTitleCoversRequest(existing, req.title) +} + +func subscriptionRequestHasExplicitEpisodes(req downloadAddRequest) bool { + return strings.TrimSpace(req.meta.SubscriptionID) != "" && len(episodeRefsFromTitle(req.title)) > 0 +} + +func downloadExplicitEpisodesCoverRequest(existing, requested string) bool { + current := parseDownloadMediaIdentity(existing) + want := parseDownloadMediaIdentity(requested) + if current.TitleKey == "" || want.TitleKey == "" { + return false + } + if current.TitleKey != want.TitleKey { + return false + } + if current.Year > 0 && want.Year > 0 && current.Year != want.Year { + return false + } + if len(current.Episodes) == 0 || len(want.Episodes) == 0 { + return false + } + currentEpisodes := map[string]struct{}{} + for _, ref := range current.Episodes { + currentEpisodes[episodeKey(ref.Season, ref.Episode)] = struct{}{} + } + for _, ref := range want.Episodes { + if _, ok := currentEpisodes[episodeKey(ref.Season, ref.Episode)]; !ok { + return false + } + } + return true +} + func downloadTaskInSubscriptionScope(row model.DownloadTask, req downloadAddRequest) bool { subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) if subscriptionID == "" { @@ -274,8 +312,8 @@ func downloadTaskInSubscriptionScope(row model.DownloadTask, req downloadAddRequ return sameOrChildPath(rowSavePath, requestSavePath) || sameOrChildPath(requestSavePath, rowSavePath) } -func (d *DownloadService) torrentExistsByIdentity(ctx context.Context, title string) bool { - query := downloadTaskIdentityKey(title) +func (d *DownloadService) torrentExistsByIdentity(ctx context.Context, req downloadAddRequest) bool { + query := downloadTaskIdentityKey(req.title) if query == "" { return false } @@ -284,7 +322,10 @@ func (d *DownloadService) torrentExistsByIdentity(ctx context.Context, title str return false } for _, torrent := range live { - if downloadTitleCoversRequest(torrent.Name, title) { + if !torrentInDownloadRequestScope(torrent, req) { + continue + } + if downloadTaskCoversAddRequest(torrent.Name, req) { return true } current := downloadTaskIdentityKey(torrent.Name) @@ -298,6 +339,18 @@ func (d *DownloadService) torrentExistsByIdentity(ctx context.Context, title str return false } +func torrentInDownloadRequestScope(torrent QBitTorrent, req downloadAddRequest) bool { + if strings.TrimSpace(req.meta.SubscriptionID) == "" { + return true + } + requestSavePath := strings.TrimSpace(req.savePath) + torrentSavePath := strings.TrimSpace(torrent.SavePath) + if requestSavePath == "" || torrentSavePath == "" { + return false + } + return sameOrChildPath(torrentSavePath, requestSavePath) || sameOrChildPath(requestSavePath, torrentSavePath) +} + 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/download_add_test.go b/internal/service/download_add_test.go index 4045074..42e14cd 100644 --- a/internal/service/download_add_test.go +++ b/internal/service/download_add_test.go @@ -478,6 +478,55 @@ func TestAddDownloadWithMetaDoesNotDedupRangeAgainstSeasonOnlyTask(t *testing.T) } } +func TestAddDownloadWithMetaDoesNotDedupSubscriptionRangeAgainstCompletePackTask(t *testing.T) { + var addCalls int32 + qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/auth/login": + _, _ = w.Write([]byte("Ok.")) + case "/api/v2/torrents/info": + http.Error(w, "temporary list unavailable", http.StatusInternalServerError) + case "/api/v2/torrents/add": + atomic.AddInt32(&addCalls, 1) + _, _ = w.Write([]byte("Ok.")) + default: + http.NotFound(w, r) + } + })) + defer qb.Close() + + db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) + repos := repository.New(db) + configureTestDefaultQB(t, repos, qb.URL) + if err := repos.Download.Create(t.Context(), &model.DownloadTask{ + UserID: "u1", + SubscriptionID: "sub-nanyang", + Source: "qbittorrent", + URL: "https://pt.example/download?id=old-complete", + Title: "Archives The Nanyang Mystery 2026 S01 Complete 2160p WEB-DL", + SavePath: "/downloads/tv", + Status: "completed", + Progress: 1, + }); err != nil { + t.Fatal(err) + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:fafafafafafafafafafafafafafafafafafafafa&dn=Archives+The+Nanyang+Mystery+2026+S01E29-E33", "/downloads/tv", DownloadTaskMeta{ + SubscriptionID: "sub-nanyang", + Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", + }) + if err != nil { + t.Fatalf("AddDownloadWithMeta returned %v, want queued because complete-pack history does not prove missing range exists", err) + } + if task == nil { + t.Fatal("task = nil, want queued task") + } + if got := atomic.LoadInt32(&addCalls); got != 1 { + t.Fatalf("qb add calls = %d, want 1", got) + } +} + func TestDownloadTitleCoversRequestKeepsCompletePackDedup(t *testing.T) { if !downloadTitleCoversRequest("Archives The Nanyang Mystery 2026 S01 Complete 2160p WEB-DL", "Archives The Nanyang Mystery 2026 S01E09-E10 2160p WEB-DL") { t.Fatal("complete pack should cover requested episode range") diff --git a/internal/service/subscription_candidate_availability_test.go b/internal/service/subscription_candidate_availability_test.go index fe126b1..831fee1 100644 --- a/internal/service/subscription_candidate_availability_test.go +++ b/internal/service/subscription_candidate_availability_test.go @@ -264,6 +264,24 @@ func TestAddSiteSearchCandidateAvailabilityTracksEpisodeRange(t *testing.T) { } } +func TestShouldSkipExistingTorrentKeepsSeriesRangeCandidate(t *testing.T) { + svc := &SubscriptionService{downloads: &DownloadService{}} + candidate := siteSearchCandidate{ + Item: SearchResult{ + Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", + DownloadURL: "https://pt/download/29-33", + }, + Season: 1, + Episode: 29, + Episodes: []int{29, 30, 31, 32, 33}, + Pack: true, + } + + if svc.shouldSkipExistingTorrent(t.Context(), "tv", candidate) { + t.Fatal("series range candidate should not be skipped by global torrent-name precheck") + } +} + func TestSelectSiteSearchCandidatesDoesNotRelaxQueryForMovies(t *testing.T) { sub := &model.Subscription{Name: "玩具总动员 5 自动订阅", Filter: "玩具总动员 5 2026", MediaType: "movie"} results := []SearchResult{ diff --git a/internal/service/subscription_site_search_helpers.go b/internal/service/subscription_site_search_helpers.go index 796088b..c649124 100644 --- a/internal/service/subscription_site_search_helpers.go +++ b/internal/service/subscription_site_search_helpers.go @@ -82,7 +82,7 @@ func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, med if s == nil || s.downloads == nil { return false } - if isSubscriptionSeriesType(mediaType) && !candidate.Pack && candidate.Episode > 0 { + if isSubscriptionSeriesType(mediaType) && candidate.Episode > 0 { return false } return s.downloads.TorrentExistsByName(ctx, candidate.Item.Title) diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index 3faf93b..f98a22e 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -15,7 +15,6 @@ import ( "time" "go.uber.org/zap" - "golang.org/x/crypto/bcrypt" "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" @@ -297,318 +296,3 @@ func telegramGroupPrivateDeliverySentHint() string { func telegramGroupPrivateDeliveryFailedHint() string { return "无法私聊发送给你。请先打开 Bot 私聊窗口发送 /start,再回群里使用命令。" } - -// cmdStart 处理 /start 命令。 -func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - name := msg.From.FirstName - if msg.From.Username != "" { - name = "@" + msg.From.Username - } - if telegramIsGroupChat(msg.Chat.Type) && len(args) > 0 { - return telegramCommandReply{Text: telegramGroupPrivateUserHint("绑定账号")} - } - if len(args) == 0 { - if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil { - user, _ := s.repo.User.FindByID(ctx, binding.UserID) - if user == nil { - _ = s.repo.DB.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", binding.ID).Error - return telegramCommandReply{Text: "之前绑定的媒体中心账号已不存在,请重新绑定:\n/start 用户名 密码"} - } - status := "未隐藏" - if user.HideAdult { - status = "已隐藏" - } - return telegramCommandReply{ - Text: fmt.Sprintf("MediaStationGo 已绑定\n\n你好 %s,当前账号:%s\n成人目录:%s", name, userNameOrFallback(user), status), - Buttons: [][]telegramInlineButton{{{ - Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult], - Data: "adult_toggle", - }}}, - } - } - hint := "如果没有账号,请联系管理员注册。" - if s.openRegEnabled(ctx) { - hint = "如果还没有账号,可直接注册:\n/register 用户名 密码\n或:/register 用户名-密码" - } - return telegramCommandReply{Text: "欢迎使用 MediaStationGo\n\n普通用户请先绑定账号:\n/start 用户名 密码\n或:/start 用户名-密码\n\n" + hint} - } - channel := s.findChannelForMessage(ctx, msg) - if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { - return telegramCommandReply{Text: telegramBindRejectText(dec, "绑定媒体中心账号")} - } - username, password := parseStartCredentials(args) - if username == "" || password == "" { - return telegramCommandReply{Text: "绑定格式不正确,请使用:\n/start 用户名 密码\n或:/start 用户名-密码"} - } - existingBinding := s.telegramBinding(ctx, msg.From.ID) - user, err := s.repo.User.FindByUsername(ctx, username) - if err != nil || user == nil { - if existingBinding != nil { - _ = s.unbindTelegramUser(ctx, msg.From.ID) - return telegramCommandReply{Text: "当前绑定的媒体账号信息已失效,已自动解绑。请使用新的用户名和密码重新绑定。"} - } - return telegramCommandReply{Text: "未找到此用户,请联系管理员注册。"} - } - if !user.IsActive { - return telegramCommandReply{Text: "此账号已被禁用,请联系管理员。"} - } - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { - if existingBinding != nil && existingBinding.UserID == user.ID { - _ = s.unbindTelegramUser(ctx, msg.From.ID) - return telegramCommandReply{Text: "当前绑定账号的密码已失效,已自动解绑。请使用新密码重新绑定。"} - } - return telegramCommandReply{Text: "账号或密码错误。"} - } - if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil { - return telegramCommandReply{Text: "绑定失败:" + err.Error()} - } - return telegramCommandReply{ - Text: fmt.Sprintf("绑定成功:%s\n\n普通用户只能使用此 Bot 管理自己的成人目录隐藏状态;系统状态、搜索、下载和统计命令仅管理员可用。", user.Username), - Buttons: [][]telegramInlineButton{{{ - Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult], - Data: "adult_toggle", - }}}, - } -} - -// cmdRegister 处理 /register 命令:在管理员开启注册后,普通用户可通过 Bot -// 注册一个新的媒体中心账号,并自动绑定到当前 Telegram 账号。 -func (s *TelegramBotService) cmdRegister(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) == 1 && looksLikeRedemptionCode(args[0]) { - return s.redeemRegisterFlow(ctx, channel, msg, args[0]) - } - if !s.openRegEnabled(ctx) { - return telegramCommandReply{Text: "注册功能未开放,请联系管理员开启后再试。"} - } - // 开注名额已用尽则拦截(容量随凭证授权实时变化,名额单独计数)。 - if c := s.loadCapacity(ctx); c.Remaining() <= 0 { - return telegramCommandReply{Text: "注册名额已满,请等待管理员重新开放或扩容授权。"} - } - if s.auth == nil { - return telegramCommandReply{Text: "注册功能暂不可用,请联系管理员。"} - } - if channel == nil { - channel = s.findChannelForMessage(ctx, msg) - } - if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { - return telegramCommandReply{Text: telegramBindRejectText(dec, "注册账号")} - } - if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil { - if user, _ := s.repo.User.FindByID(ctx, binding.UserID); user != nil { - return telegramCommandReply{Text: fmt.Sprintf("当前 Telegram 已绑定账号:%s,无需重复注册。\n如需切换账号请使用 /start 用户名 密码。", userNameOrFallback(user))} - } - } - username, password := parseStartCredentials(args) - if username == "" || password == "" { - return telegramCommandReply{Text: "注册格式不正确,请使用:\n/register 用户名 密码\n或:/register 用户名-密码"} - } - user, _, err := s.auth.Register(ctx, username, password) - if err != nil { - switch { - case errors.Is(err, ErrUsernameTaken): - return telegramCommandReply{Text: "该用户名已被占用,请换一个;如果是你本人的账号,请改用 /start 用户名 密码 绑定。"} - case errors.Is(err, ErrUserLimitReached): - return telegramCommandReply{Text: "注册失败:已达到用户数量上限,请联系管理员。"} - default: - return telegramCommandReply{Text: "注册失败:" + err.Error()} - } - } - // 注册成功,扣减一个开注名额(名额用尽自动关闭注册)。 - s.consumeOpenRegSlot(ctx) - if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil { - return telegramCommandReply{Text: fmt.Sprintf("账号 %s 注册成功,但自动绑定失败:%s\n请稍后使用 /start %s 密码 重新绑定。", user.Username, err.Error(), user.Username)} - } - return telegramCommandReply{ - Text: fmt.Sprintf("注册并绑定成功:%s\n\n你现在可以用此账号登录网页与第三方客户端。普通用户只能在此 Bot 管理成人目录显隐;其他功能仅管理员可用。", user.Username), - Buttons: [][]telegramInlineButton{{{ - Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult], - Data: "adult_toggle", - }}}, - } -} - -// cmdRegistrationToggle handles /registration and /openreg. It uses the same -// quota-aware open-registration state as the inline Bot menu. -func (s *TelegramBotService) cmdRegistrationToggle(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 || strings.EqualFold(strings.TrimSpace(args[0]), "status") { - c := s.loadCapacity(ctx) - state := "已关闭" - if c.OpenRegOn { - if c.OpenRegLimit > 0 { - state = fmt.Sprintf("已开启(%d/%d 名额)", c.OpenRegUsed, c.OpenRegLimit) - } else { - state = "已开启(不限名额,受授权上限约束)" - } - } - return telegramCommandReply{Text: fmt.Sprintf("普通用户 Bot 注册功能当前%s。\n剩余可注册:%d 人。\n\n开启:/registration on 10\n不限:/registration on 0\n关闭:/registration off", state, c.Remaining())} - } - switch strings.ToLower(strings.TrimSpace(args[0])) { - case "on", "true", "1", "open", "enable", "enabled", "开启", "打开", "开": - limit := 0 - if len(args) > 1 { - n, err := strconv.Atoi(strings.TrimSpace(args[1])) - if err != nil || n < 0 { - return telegramCommandReply{Text: "名额必须是非负整数,0 表示不限名额。"} - } - limit = n - } - if err := s.openRegistration(ctx, limit); err != nil { - return telegramCommandReply{Text: "开启失败:" + err.Error()} - } - label := "不限名额" - if limit > 0 { - label = fmt.Sprintf("%d 个名额", limit) - } - return telegramCommandReply{Text: "普通用户 Bot 注册功能已开启:" + label + "。"} - case "off", "false", "0", "close", "disable", "disabled", "关闭", "关": - if err := s.closeRegistration(ctx); err != nil { - return telegramCommandReply{Text: "关闭失败:" + err.Error()} - } - return telegramCommandReply{Text: "普通用户 Bot 注册功能已关闭。"} - default: - return telegramCommandReply{Text: "参数无效,请使用 /registration on [名额] 或 /registration off。"} - } -} - -// cmdHelp 处理 /help 命令。 -func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) string { - channel := s.findChannelForMessage(ctx, msg) - if telegramIsGroupChat(msg.Chat.Type) { - adminHint := "" - if s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { - adminHint = "\n\n管理员可在已绑定群组直接发送文本管理命令;管理面板和账号凭据操作请私聊 Bot。" - } - return "MediaStationGo 群组可用命令\n\n" + - "/menu — 打开群组自助菜单\n" + - "/account — 查看账号状态\n" + - "/signin — 签到\n" + - "/devices — 查看登录设备\n" + - "/kick all|编号 — 踢下线设备\n" + - "/hideadult on|off — 隐藏或显示成人目录\n\n" + - "绑定、注册、兑换、改名、改密等包含敏感信息的操作请私聊 Bot。" + - adminHint - } - if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { - register := "" - if s.openRegEnabled(ctx) { - register = "/register 用户名 密码 — 注册新账号\n" - } - return "MediaStationGo 用户命令\n\n" + - register + - "/start 用户名 密码 — 绑定账号\n" + - "/account — 查看账号状态\n" + - "/signin — 签到\n" + - "/devices — 查看登录设备\n" + - "/kick all|编号 — 踢下线设备\n" + - "/setname 当前密码 新用户名 — 修改用户名\n" + - "/setpass 当前密码 新密码 — 修改密码\n" + - "/redeem 兑换码 — 注册或续期兑换\n" + - "/hideadult on|off — 隐藏或显示成人目录\n\n" + - "系统状态、搜索、下载列表与统计命令仅管理员可用。" - } - return "MediaStationGo 命令列表\n\n" + - "/start — 开始使用\n" + - "/help — 帮助信息\n" + - "/account / /devices / /kick all|编号 — 用户自助设备管理\n" + - "/signin / /redeem 兑换码 — 签到与兑换\n" + - "/setname 当前密码 新用户名 / /setpass 当前密码 新密码 — 用户自助改名改密\n" + - "/register 用户名 密码 — 注册新账号(需管理员开启)\n" + - "/registration on [名额]|off — 开启/关闭普通用户注册(管理员)\n" + - "/capacity / /users — 容量与用户管理(管理员)\n" + - "/gencode register|renew 天数 [有效天数] — 生成兑换码(管理员)\n" + - "/renew_user 用户名 天数 / /delete_user 用户名 confirm — 续期/删除用户(管理员)\n" + - "/unbind 用户1 用户2 — 批量解绑 Telegram 绑定(管理员)\n" + - "/unbind_duplicates / /unbind_inactive 天数 — 清理重复/无效绑定或久未登录绑定(管理员)\n" + - "/antishare on play=3 login=3 warn=2 — 防共享策略(管理员)\n" + - "/cleanup run — 预览保号清理候选(管理员)\n" + - "/cleanup run confirm — 确认清理候选账号(管理员)\n" + - "/cleanup on|off — 保号规则开关(管理员)\n" + - "/cleanup_rule list|add|edit|修改|del|enable|disable — Mgo 保号规则(管理员)\n" + - "/ban 用户名 / /unban 用户名 — 禁用/解禁用户(管理员)\n" + - "/hideadult on|off — 隐藏/显示当前绑定账号的成人目录\n" + - "/status — 系统运行状态\n" + - "/search 关键词 — 搜索媒体库\n" + - "/downloads — 下载列表\n" + - "/stats — 媒体库统计\n\n" + - telegramMgoAdminCommandHelp() + "\n\n" + - "自动推送事件:\n" + - "• 订阅命中新资源\n" + - "• 下载任务完成\n" + - "• 刮削失败告警\n" + - "• 系统异常通知" -} - -func telegramMgoAdminCommandHelp() string { - return "Mgo 管理命令(管理员可用,已注册到命令栏):\n" + - "用户:/ucr 用户名 密码 [天数] 创建账号;/uinfo 用户名 查询账号;/rmemby 用户名 confirm 删除账号;/only_rm_record tg:ID|用户名 仅删 Bot 绑定;/renewall 天数 confirm 批量续期。\n" + - "审计:/userip 用户名 查用户 IP;/auditip IP 按 IP 审计;/auditdevice 关键词 按终端设备审计;/auditclient 关键词 按客户端审计;/udeviceid 设备ID 按设备指纹审计。\n" + - "清理:/syncunbound 检查未绑定账号;/syncgroupm 校验群成员;/check_ex 检查过期账号;/deleted 按保号规则预览清理候选。\n" + - "权限:/embyadmin 用户名 on|off 设置管理员;/banall confirm//unbanall confirm 批量禁用/解禁;/prouser 用户名//revuser 用户名 管理保护名单;/embylibs_blockall//embylibs_unblockall 批量禁用/开放媒体库权限。\n" + - "运维:/proadmin TelegramID//revadmin TelegramID 管理 Bot 管理员;/backup_db 备份数据库;/restore_from_db 文件名 confirm 恢复数据库。\n" + - "说明:重复别名如 /low_activity、/urm 仍可兼容识别,但不显示在命令栏。" -} - -// cmdStatus 处理 /status 命令。 -func (s *TelegramBotService) cmdHideAdult(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - channel := s.findChannelForMessage(ctx, msg) - if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { - return telegramCommandReply{Text: telegramBindRejectText(dec, "使用成人目录隐藏开关")} - } - binding := s.telegramBinding(ctx, msg.From.ID) - if binding == nil { - return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"} - } - user, err := s.repo.User.FindByID(ctx, binding.UserID) - if err != nil || user == nil { - return telegramCommandReply{Text: "绑定用户不存在,请重新 /start 绑定。"} - } - next := true - if len(args) > 0 { - switch strings.ToLower(strings.TrimSpace(args[0])) { - case "off", "false", "0", "show", "显示", "关闭": - next = false - case "on", "true", "1", "hide", "隐藏", "开启": - next = true - default: - next = !user.HideAdult - } - } else { - next = !user.HideAdult - } - if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"hide_adult": next}); err != nil { - return telegramCommandReply{Text: "更新失败:" + err.Error()} - } - status := map[bool]string{true: "已隐藏", false: "已显示"}[next] - return telegramCommandReply{ - Text: "成人目录" + status + "。此设置会同步影响网页与第三方客户端。", - Buttons: [][]telegramInlineButton{{{ - Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[next], - Data: "adult_toggle", - }}}, - } -} - -// ── Webhook Management ── - -// SetWebhook 注册 Telegram Bot Webhook URL。 -func (s *TelegramBotService) SetWebhook(ctx context.Context, botToken, webhookURL string) error { - cfg := map[string]string{"bot_token": botToken} - if err := registerTelegramBotCommands(ctx, cfg); err != nil && s.log != nil { - s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) - } - payload := map[string]interface{}{ - "url": webhookURL, - "allowed_updates": []string{"message", "callback_query"}, - } - return telegramPostJSON(ctx, cfg, "setWebhook", payload, 15*time.Second) -} - -// GetWebhookInfo 获取 Webhook 配置信息。 -func (s *TelegramBotService) GetWebhookInfo(ctx context.Context, botToken string) (map[string]interface{}, error) { - cfg := map[string]string{"bot_token": botToken} - var result map[string]interface{} - if err := telegramGetJSONDecode(ctx, cfg, "getWebhookInfo", 10*time.Second, &result); err != nil { - return nil, err - } - return result, nil -} diff --git a/internal/service/telegram_bot_commands_core.go b/internal/service/telegram_bot_commands_core.go new file mode 100644 index 0000000..b88471e --- /dev/null +++ b/internal/service/telegram_bot_commands_core.go @@ -0,0 +1,303 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/bcrypt" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +// cmdStart 处理 /start 命令。 +func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { + name := msg.From.FirstName + if msg.From.Username != "" { + name = "@" + msg.From.Username + } + if telegramIsGroupChat(msg.Chat.Type) && len(args) > 0 { + return telegramCommandReply{Text: telegramGroupPrivateUserHint("绑定账号")} + } + if len(args) == 0 { + if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil { + user, _ := s.repo.User.FindByID(ctx, binding.UserID) + if user == nil { + _ = s.repo.DB.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", binding.ID).Error + return telegramCommandReply{Text: "之前绑定的媒体中心账号已不存在,请重新绑定:\n/start 用户名 密码"} + } + status := "未隐藏" + if user.HideAdult { + status = "已隐藏" + } + return telegramCommandReply{ + Text: fmt.Sprintf("MediaStationGo 已绑定\n\n你好 %s,当前账号:%s\n成人目录:%s", name, userNameOrFallback(user), status), + Buttons: [][]telegramInlineButton{{{ + Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult], + Data: "adult_toggle", + }}}, + } + } + hint := "如果没有账号,请联系管理员注册。" + if s.openRegEnabled(ctx) { + hint = "如果还没有账号,可直接注册:\n/register 用户名 密码\n或:/register 用户名-密码" + } + return telegramCommandReply{Text: "欢迎使用 MediaStationGo\n\n普通用户请先绑定账号:\n/start 用户名 密码\n或:/start 用户名-密码\n\n" + hint} + } + channel := s.findChannelForMessage(ctx, msg) + if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { + return telegramCommandReply{Text: telegramBindRejectText(dec, "绑定媒体中心账号")} + } + username, password := parseStartCredentials(args) + if username == "" || password == "" { + return telegramCommandReply{Text: "绑定格式不正确,请使用:\n/start 用户名 密码\n或:/start 用户名-密码"} + } + existingBinding := s.telegramBinding(ctx, msg.From.ID) + user, err := s.repo.User.FindByUsername(ctx, username) + if err != nil || user == nil { + if existingBinding != nil { + _ = s.unbindTelegramUser(ctx, msg.From.ID) + return telegramCommandReply{Text: "当前绑定的媒体账号信息已失效,已自动解绑。请使用新的用户名和密码重新绑定。"} + } + return telegramCommandReply{Text: "未找到此用户,请联系管理员注册。"} + } + if !user.IsActive { + return telegramCommandReply{Text: "此账号已被禁用,请联系管理员。"} + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + if existingBinding != nil && existingBinding.UserID == user.ID { + _ = s.unbindTelegramUser(ctx, msg.From.ID) + return telegramCommandReply{Text: "当前绑定账号的密码已失效,已自动解绑。请使用新密码重新绑定。"} + } + return telegramCommandReply{Text: "账号或密码错误。"} + } + if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil { + return telegramCommandReply{Text: "绑定失败:" + err.Error()} + } + return telegramCommandReply{ + Text: fmt.Sprintf("绑定成功:%s\n\n普通用户只能使用此 Bot 管理自己的成人目录隐藏状态;系统状态、搜索、下载和统计命令仅管理员可用。", user.Username), + Buttons: [][]telegramInlineButton{{{ + Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult], + Data: "adult_toggle", + }}}, + } +} + +// cmdRegister 处理 /register 命令:在管理员开启注册后,普通用户可通过 Bot +// 注册一个新的媒体中心账号,并自动绑定到当前 Telegram 账号。 +func (s *TelegramBotService) cmdRegister(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply { + if len(args) == 1 && looksLikeRedemptionCode(args[0]) { + return s.redeemRegisterFlow(ctx, channel, msg, args[0]) + } + if !s.openRegEnabled(ctx) { + return telegramCommandReply{Text: "注册功能未开放,请联系管理员开启后再试。"} + } + // 开注名额已用尽则拦截(容量随凭证授权实时变化,名额单独计数)。 + if c := s.loadCapacity(ctx); c.Remaining() <= 0 { + return telegramCommandReply{Text: "注册名额已满,请等待管理员重新开放或扩容授权。"} + } + if s.auth == nil { + return telegramCommandReply{Text: "注册功能暂不可用,请联系管理员。"} + } + if channel == nil { + channel = s.findChannelForMessage(ctx, msg) + } + if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { + return telegramCommandReply{Text: telegramBindRejectText(dec, "注册账号")} + } + if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil { + if user, _ := s.repo.User.FindByID(ctx, binding.UserID); user != nil { + return telegramCommandReply{Text: fmt.Sprintf("当前 Telegram 已绑定账号:%s,无需重复注册。\n如需切换账号请使用 /start 用户名 密码。", userNameOrFallback(user))} + } + } + username, password := parseStartCredentials(args) + if username == "" || password == "" { + return telegramCommandReply{Text: "注册格式不正确,请使用:\n/register 用户名 密码\n或:/register 用户名-密码"} + } + user, _, err := s.auth.Register(ctx, username, password) + if err != nil { + switch { + case errors.Is(err, ErrUsernameTaken): + return telegramCommandReply{Text: "该用户名已被占用,请换一个;如果是你本人的账号,请改用 /start 用户名 密码 绑定。"} + case errors.Is(err, ErrUserLimitReached): + return telegramCommandReply{Text: "注册失败:已达到用户数量上限,请联系管理员。"} + default: + return telegramCommandReply{Text: "注册失败:" + err.Error()} + } + } + // 注册成功,扣减一个开注名额(名额用尽自动关闭注册)。 + s.consumeOpenRegSlot(ctx) + if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil { + return telegramCommandReply{Text: fmt.Sprintf("账号 %s 注册成功,但自动绑定失败:%s\n请稍后使用 /start %s 密码 重新绑定。", user.Username, err.Error(), user.Username)} + } + return telegramCommandReply{ + Text: fmt.Sprintf("注册并绑定成功:%s\n\n你现在可以用此账号登录网页与第三方客户端。普通用户只能在此 Bot 管理成人目录显隐;其他功能仅管理员可用。", user.Username), + Buttons: [][]telegramInlineButton{{{ + Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult], + Data: "adult_toggle", + }}}, + } +} + +// cmdRegistrationToggle handles /registration and /openreg. It uses the same +// quota-aware open-registration state as the inline Bot menu. +func (s *TelegramBotService) cmdRegistrationToggle(ctx context.Context, args []string) telegramCommandReply { + if len(args) == 0 || strings.EqualFold(strings.TrimSpace(args[0]), "status") { + c := s.loadCapacity(ctx) + state := "已关闭" + if c.OpenRegOn { + if c.OpenRegLimit > 0 { + state = fmt.Sprintf("已开启(%d/%d 名额)", c.OpenRegUsed, c.OpenRegLimit) + } else { + state = "已开启(不限名额,受授权上限约束)" + } + } + return telegramCommandReply{Text: fmt.Sprintf("普通用户 Bot 注册功能当前%s。\n剩余可注册:%d 人。\n\n开启:/registration on 10\n不限:/registration on 0\n关闭:/registration off", state, c.Remaining())} + } + switch strings.ToLower(strings.TrimSpace(args[0])) { + case "on", "true", "1", "open", "enable", "enabled", "开启", "打开", "开": + limit := 0 + if len(args) > 1 { + n, err := strconv.Atoi(strings.TrimSpace(args[1])) + if err != nil || n < 0 { + return telegramCommandReply{Text: "名额必须是非负整数,0 表示不限名额。"} + } + limit = n + } + if err := s.openRegistration(ctx, limit); err != nil { + return telegramCommandReply{Text: "开启失败:" + err.Error()} + } + label := "不限名额" + if limit > 0 { + label = fmt.Sprintf("%d 个名额", limit) + } + return telegramCommandReply{Text: "普通用户 Bot 注册功能已开启:" + label + "。"} + case "off", "false", "0", "close", "disable", "disabled", "关闭", "关": + if err := s.closeRegistration(ctx); err != nil { + return telegramCommandReply{Text: "关闭失败:" + err.Error()} + } + return telegramCommandReply{Text: "普通用户 Bot 注册功能已关闭。"} + default: + return telegramCommandReply{Text: "参数无效,请使用 /registration on [名额] 或 /registration off。"} + } +} + +// cmdHelp 处理 /help 命令。 +func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) string { + channel := s.findChannelForMessage(ctx, msg) + if telegramIsGroupChat(msg.Chat.Type) { + adminHint := "" + if s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + adminHint = "\n\n管理员可在已绑定群组直接发送文本管理命令;管理面板和账号凭据操作请私聊 Bot。" + } + return "MediaStationGo 群组可用命令\n\n" + + "/menu — 打开群组自助菜单\n" + + "/account — 查看账号状态\n" + + "/signin — 签到\n" + + "/devices — 查看登录设备\n" + + "/kick all|编号 — 踢下线设备\n" + + "/hideadult on|off — 隐藏或显示成人目录\n\n" + + "绑定、注册、兑换、改名、改密等包含敏感信息的操作请私聊 Bot。" + + adminHint + } + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + register := "" + if s.openRegEnabled(ctx) { + register = "/register 用户名 密码 — 注册新账号\n" + } + return "MediaStationGo 用户命令\n\n" + + register + + "/start 用户名 密码 — 绑定账号\n" + + "/account — 查看账号状态\n" + + "/signin — 签到\n" + + "/devices — 查看登录设备\n" + + "/kick all|编号 — 踢下线设备\n" + + "/setname 当前密码 新用户名 — 修改用户名\n" + + "/setpass 当前密码 新密码 — 修改密码\n" + + "/redeem 兑换码 — 注册或续期兑换\n" + + "/hideadult on|off — 隐藏或显示成人目录\n\n" + + "系统状态、搜索、下载列表与统计命令仅管理员可用。" + } + return "MediaStationGo 命令列表\n\n" + + "/start — 开始使用\n" + + "/help — 帮助信息\n" + + "/account / /devices / /kick all|编号 — 用户自助设备管理\n" + + "/signin / /redeem 兑换码 — 签到与兑换\n" + + "/setname 当前密码 新用户名 / /setpass 当前密码 新密码 — 用户自助改名改密\n" + + "/register 用户名 密码 — 注册新账号(需管理员开启)\n" + + "/registration on [名额]|off — 开启/关闭普通用户注册(管理员)\n" + + "/capacity / /users — 容量与用户管理(管理员)\n" + + "/gencode register|renew 天数 [有效天数] — 生成兑换码(管理员)\n" + + "/renew_user 用户名 天数 / /delete_user 用户名 confirm — 续期/删除用户(管理员)\n" + + "/unbind 用户1 用户2 — 批量解绑 Telegram 绑定(管理员)\n" + + "/unbind_duplicates / /unbind_inactive 天数 — 清理重复/无效绑定或久未登录绑定(管理员)\n" + + "/antishare on play=3 login=3 warn=2 — 防共享策略(管理员)\n" + + "/cleanup run — 预览保号清理候选(管理员)\n" + + "/cleanup run confirm — 确认清理候选账号(管理员)\n" + + "/cleanup on|off — 保号规则开关(管理员)\n" + + "/cleanup_rule list|add|edit|修改|del|enable|disable — Mgo 保号规则(管理员)\n" + + "/ban 用户名 / /unban 用户名 — 禁用/解禁用户(管理员)\n" + + "/hideadult on|off — 隐藏/显示当前绑定账号的成人目录\n" + + "/status — 系统运行状态\n" + + "/search 关键词 — 搜索媒体库\n" + + "/downloads — 下载列表\n" + + "/stats — 媒体库统计\n\n" + + telegramMgoAdminCommandHelp() + "\n\n" + + "自动推送事件:\n" + + "• 订阅命中新资源\n" + + "• 下载任务完成\n" + + "• 刮削失败告警\n" + + "• 系统异常通知" +} + +func telegramMgoAdminCommandHelp() string { + return "Mgo 管理命令(管理员可用,已注册到命令栏):\n" + + "用户:/ucr 用户名 密码 [天数] 创建账号;/uinfo 用户名 查询账号;/rmemby 用户名 confirm 删除账号;/only_rm_record tg:ID|用户名 仅删 Bot 绑定;/renewall 天数 confirm 批量续期。\n" + + "审计:/userip 用户名 查用户 IP;/auditip IP 按 IP 审计;/auditdevice 关键词 按终端设备审计;/auditclient 关键词 按客户端审计;/udeviceid 设备ID 按设备指纹审计。\n" + + "清理:/syncunbound 检查未绑定账号;/syncgroupm 校验群成员;/check_ex 检查过期账号;/deleted 按保号规则预览清理候选。\n" + + "权限:/embyadmin 用户名 on|off 设置管理员;/banall confirm//unbanall confirm 批量禁用/解禁;/prouser 用户名//revuser 用户名 管理保护名单;/embylibs_blockall//embylibs_unblockall 批量禁用/开放媒体库权限。\n" + + "运维:/proadmin TelegramID//revadmin TelegramID 管理 Bot 管理员;/backup_db 备份数据库;/restore_from_db 文件名 confirm 恢复数据库。\n" + + "说明:重复别名如 /low_activity、/urm 仍可兼容识别,但不显示在命令栏。" +} + +// cmdStatus 处理 /status 命令。 +func (s *TelegramBotService) cmdHideAdult(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { + channel := s.findChannelForMessage(ctx, msg) + if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { + return telegramCommandReply{Text: telegramBindRejectText(dec, "使用成人目录隐藏开关")} + } + binding := s.telegramBinding(ctx, msg.From.ID) + if binding == nil { + return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"} + } + user, err := s.repo.User.FindByID(ctx, binding.UserID) + if err != nil || user == nil { + return telegramCommandReply{Text: "绑定用户不存在,请重新 /start 绑定。"} + } + next := true + if len(args) > 0 { + switch strings.ToLower(strings.TrimSpace(args[0])) { + case "off", "false", "0", "show", "显示", "关闭": + next = false + case "on", "true", "1", "hide", "隐藏", "开启": + next = true + default: + next = !user.HideAdult + } + } else { + next = !user.HideAdult + } + if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"hide_adult": next}); err != nil { + return telegramCommandReply{Text: "更新失败:" + err.Error()} + } + status := map[bool]string{true: "已隐藏", false: "已显示"}[next] + return telegramCommandReply{ + Text: "成人目录" + status + "。此设置会同步影响网页与第三方客户端。", + Buttons: [][]telegramInlineButton{{{ + Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[next], + Data: "adult_toggle", + }}}, + } +} diff --git a/internal/service/telegram_webhook_admin.go b/internal/service/telegram_webhook_admin.go new file mode 100644 index 0000000..c3a70da --- /dev/null +++ b/internal/service/telegram_webhook_admin.go @@ -0,0 +1,31 @@ +package service + +import ( + "context" + "time" + + "go.uber.org/zap" +) + +// SetWebhook 注册 Telegram Bot Webhook URL。 +func (s *TelegramBotService) SetWebhook(ctx context.Context, botToken, webhookURL string) error { + cfg := map[string]string{"bot_token": botToken} + if err := registerTelegramBotCommands(ctx, cfg); err != nil && s.log != nil { + s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) + } + payload := map[string]interface{}{ + "url": webhookURL, + "allowed_updates": []string{"message", "callback_query"}, + } + return telegramPostJSON(ctx, cfg, "setWebhook", payload, 15*time.Second) +} + +// GetWebhookInfo 获取 Webhook 配置信息。 +func (s *TelegramBotService) GetWebhookInfo(ctx context.Context, botToken string) (map[string]interface{}, error) { + cfg := map[string]string{"bot_token": botToken} + var result map[string]interface{} + if err := telegramGetJSONDecode(ctx, cfg, "getWebhookInfo", 10*time.Second, &result); err != nil { + return nil, err + } + return result, nil +}