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
+}