mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 22:06:38 +08:00
fix subscription range dedup
This commit is contained in:
@@ -56,7 +56,7 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
|
|||||||
if !d.qb.IsConfigured() {
|
if !d.qb.IsConfigured() {
|
||||||
return nil, errors.New("no default downloader configured")
|
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)
|
task, err := d.createTask(ctx, userID, urlStr, req.savePath, req.meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -250,13 +250,51 @@ func (d *DownloadService) findExistingDownloadTask(ctx context.Context, req down
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
current := downloadTaskIdentityKey(rows[i].Title)
|
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 &rows[i], true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, false
|
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 {
|
func downloadTaskInSubscriptionScope(row model.DownloadTask, req downloadAddRequest) bool {
|
||||||
subscriptionID := strings.TrimSpace(req.meta.SubscriptionID)
|
subscriptionID := strings.TrimSpace(req.meta.SubscriptionID)
|
||||||
if subscriptionID == "" {
|
if subscriptionID == "" {
|
||||||
@@ -274,8 +312,8 @@ func downloadTaskInSubscriptionScope(row model.DownloadTask, req downloadAddRequ
|
|||||||
return sameOrChildPath(rowSavePath, requestSavePath) || sameOrChildPath(requestSavePath, rowSavePath)
|
return sameOrChildPath(rowSavePath, requestSavePath) || sameOrChildPath(requestSavePath, rowSavePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DownloadService) torrentExistsByIdentity(ctx context.Context, title string) bool {
|
func (d *DownloadService) torrentExistsByIdentity(ctx context.Context, req downloadAddRequest) bool {
|
||||||
query := downloadTaskIdentityKey(title)
|
query := downloadTaskIdentityKey(req.title)
|
||||||
if query == "" {
|
if query == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -284,7 +322,10 @@ func (d *DownloadService) torrentExistsByIdentity(ctx context.Context, title str
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, torrent := range live {
|
for _, torrent := range live {
|
||||||
if downloadTitleCoversRequest(torrent.Name, title) {
|
if !torrentInDownloadRequestScope(torrent, req) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if downloadTaskCoversAddRequest(torrent.Name, req) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
current := downloadTaskIdentityKey(torrent.Name)
|
current := downloadTaskIdentityKey(torrent.Name)
|
||||||
@@ -298,6 +339,18 @@ func (d *DownloadService) torrentExistsByIdentity(ctx context.Context, title str
|
|||||||
return false
|
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) {
|
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 == "" {
|
||||||
|
|||||||
@@ -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) {
|
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") {
|
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")
|
t.Fatal("complete pack should cover requested episode range")
|
||||||
|
|||||||
@@ -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) {
|
func TestSelectSiteSearchCandidatesDoesNotRelaxQueryForMovies(t *testing.T) {
|
||||||
sub := &model.Subscription{Name: "玩具总动员 5 自动订阅", Filter: "玩具总动员 5 2026", MediaType: "movie"}
|
sub := &model.Subscription{Name: "玩具总动员 5 自动订阅", Filter: "玩具总动员 5 2026", MediaType: "movie"}
|
||||||
results := []SearchResult{
|
results := []SearchResult{
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, med
|
|||||||
if s == nil || s.downloads == nil {
|
if s == nil || s.downloads == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if isSubscriptionSeriesType(mediaType) && !candidate.Pack && candidate.Episode > 0 {
|
if isSubscriptionSeriesType(mediaType) && candidate.Episode > 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return s.downloads.TorrentExistsByName(ctx, candidate.Item.Title)
|
return s.downloads.TorrentExistsByName(ctx, candidate.Item.Title)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
@@ -297,318 +296,3 @@ func telegramGroupPrivateDeliverySentHint() string {
|
|||||||
func telegramGroupPrivateDeliveryFailedHint() string {
|
func telegramGroupPrivateDeliveryFailedHint() string {
|
||||||
return "无法私聊发送给你。请先打开 Bot 私聊窗口发送 <code>/start</code>,再回群里使用命令。"
|
return "无法私聊发送给你。请先打开 Bot 私聊窗口发送 <code>/start</code>,再回群里使用命令。"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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<code>/start 用户名 密码</code>"}
|
|
||||||
}
|
|
||||||
status := "未隐藏"
|
|
||||||
if user.HideAdult {
|
|
||||||
status = "已隐藏"
|
|
||||||
}
|
|
||||||
return telegramCommandReply{
|
|
||||||
Text: fmt.Sprintf("<b>MediaStationGo 已绑定</b>\n\n你好 %s,当前账号:<b>%s</b>\n成人目录:<b>%s</b>", name, userNameOrFallback(user), status),
|
|
||||||
Buttons: [][]telegramInlineButton{{{
|
|
||||||
Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult],
|
|
||||||
Data: "adult_toggle",
|
|
||||||
}}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hint := "如果没有账号,请联系管理员注册。"
|
|
||||||
if s.openRegEnabled(ctx) {
|
|
||||||
hint = "如果还没有账号,可直接注册:\n<code>/register 用户名 密码</code>\n或:<code>/register 用户名-密码</code>"
|
|
||||||
}
|
|
||||||
return telegramCommandReply{Text: "<b>欢迎使用 MediaStationGo</b>\n\n普通用户请先绑定账号:\n<code>/start 用户名 密码</code>\n或:<code>/start 用户名-密码</code>\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<code>/start 用户名 密码</code>\n或:<code>/start 用户名-密码</code>"}
|
|
||||||
}
|
|
||||||
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("绑定成功:<b>%s</b>\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 已绑定账号:<b>%s</b>,无需重复注册。\n如需切换账号请使用 <code>/start 用户名 密码</code>。", userNameOrFallback(user))}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
username, password := parseStartCredentials(args)
|
|
||||||
if username == "" || password == "" {
|
|
||||||
return telegramCommandReply{Text: "注册格式不正确,请使用:\n<code>/register 用户名 密码</code>\n或:<code>/register 用户名-密码</code>"}
|
|
||||||
}
|
|
||||||
user, _, err := s.auth.Register(ctx, username, password)
|
|
||||||
if err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, ErrUsernameTaken):
|
|
||||||
return telegramCommandReply{Text: "该用户名已被占用,请换一个;如果是你本人的账号,请改用 <code>/start 用户名 密码</code> 绑定。"}
|
|
||||||
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("账号 <b>%s</b> 注册成功,但自动绑定失败:%s\n请稍后使用 <code>/start %s 密码</code> 重新绑定。", user.Username, err.Error(), user.Username)}
|
|
||||||
}
|
|
||||||
return telegramCommandReply{
|
|
||||||
Text: fmt.Sprintf("注册并绑定成功:<b>%s</b>\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 注册功能当前<b>%s</b>。\n剩余可注册:<b>%d</b> 人。\n\n开启:<code>/registration on 10</code>\n不限:<code>/registration on 0</code>\n关闭:<code>/registration off</code>", 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: "参数无效,请使用 <code>/registration on [名额]</code> 或 <code>/registration off</code>。"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 "<b>MediaStationGo 群组可用命令</b>\n\n" +
|
|
||||||
"<b>/menu</b> — 打开群组自助菜单\n" +
|
|
||||||
"<b>/account</b> — 查看账号状态\n" +
|
|
||||||
"<b>/signin</b> — 签到\n" +
|
|
||||||
"<b>/devices</b> — 查看登录设备\n" +
|
|
||||||
"<b>/kick all|编号</b> — 踢下线设备\n" +
|
|
||||||
"<b>/hideadult on|off</b> — 隐藏或显示成人目录\n\n" +
|
|
||||||
"绑定、注册、兑换、改名、改密等包含敏感信息的操作请私聊 Bot。" +
|
|
||||||
adminHint
|
|
||||||
}
|
|
||||||
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
|
|
||||||
register := ""
|
|
||||||
if s.openRegEnabled(ctx) {
|
|
||||||
register = "<b>/register 用户名 密码</b> — 注册新账号\n"
|
|
||||||
}
|
|
||||||
return "<b>MediaStationGo 用户命令</b>\n\n" +
|
|
||||||
register +
|
|
||||||
"<b>/start 用户名 密码</b> — 绑定账号\n" +
|
|
||||||
"<b>/account</b> — 查看账号状态\n" +
|
|
||||||
"<b>/signin</b> — 签到\n" +
|
|
||||||
"<b>/devices</b> — 查看登录设备\n" +
|
|
||||||
"<b>/kick all|编号</b> — 踢下线设备\n" +
|
|
||||||
"<b>/setname 当前密码 新用户名</b> — 修改用户名\n" +
|
|
||||||
"<b>/setpass 当前密码 新密码</b> — 修改密码\n" +
|
|
||||||
"<b>/redeem 兑换码</b> — 注册或续期兑换\n" +
|
|
||||||
"<b>/hideadult on|off</b> — 隐藏或显示成人目录\n\n" +
|
|
||||||
"系统状态、搜索、下载列表与统计命令仅管理员可用。"
|
|
||||||
}
|
|
||||||
return "<b>MediaStationGo 命令列表</b>\n\n" +
|
|
||||||
"<b>/start</b> — 开始使用\n" +
|
|
||||||
"<b>/help</b> — 帮助信息\n" +
|
|
||||||
"<b>/account</b> / <b>/devices</b> / <b>/kick all|编号</b> — 用户自助设备管理\n" +
|
|
||||||
"<b>/signin</b> / <b>/redeem 兑换码</b> — 签到与兑换\n" +
|
|
||||||
"<b>/setname 当前密码 新用户名</b> / <b>/setpass 当前密码 新密码</b> — 用户自助改名改密\n" +
|
|
||||||
"<b>/register 用户名 密码</b> — 注册新账号(需管理员开启)\n" +
|
|
||||||
"<b>/registration on [名额]|off</b> — 开启/关闭普通用户注册(管理员)\n" +
|
|
||||||
"<b>/capacity</b> / <b>/users</b> — 容量与用户管理(管理员)\n" +
|
|
||||||
"<b>/gencode register|renew 天数 [有效天数]</b> — 生成兑换码(管理员)\n" +
|
|
||||||
"<b>/renew_user 用户名 天数</b> / <b>/delete_user 用户名 confirm</b> — 续期/删除用户(管理员)\n" +
|
|
||||||
"<b>/unbind 用户1 用户2</b> — 批量解绑 Telegram 绑定(管理员)\n" +
|
|
||||||
"<b>/unbind_duplicates</b> / <b>/unbind_inactive 天数</b> — 清理重复/无效绑定或久未登录绑定(管理员)\n" +
|
|
||||||
"<b>/antishare on play=3 login=3 warn=2</b> — 防共享策略(管理员)\n" +
|
|
||||||
"<b>/cleanup run</b> — 预览保号清理候选(管理员)\n" +
|
|
||||||
"<b>/cleanup run confirm</b> — 确认清理候选账号(管理员)\n" +
|
|
||||||
"<b>/cleanup on|off</b> — 保号规则开关(管理员)\n" +
|
|
||||||
"<b>/cleanup_rule list|add|edit|修改|del|enable|disable</b> — Mgo 保号规则(管理员)\n" +
|
|
||||||
"<b>/ban 用户名</b> / <b>/unban 用户名</b> — 禁用/解禁用户(管理员)\n" +
|
|
||||||
"<b>/hideadult on|off</b> — 隐藏/显示当前绑定账号的成人目录\n" +
|
|
||||||
"<b>/status</b> — 系统运行状态\n" +
|
|
||||||
"<b>/search 关键词</b> — 搜索媒体库\n" +
|
|
||||||
"<b>/downloads</b> — 下载列表\n" +
|
|
||||||
"<b>/stats</b> — 媒体库统计\n\n" +
|
|
||||||
telegramMgoAdminCommandHelp() + "\n\n" +
|
|
||||||
"<b>自动推送事件:</b>\n" +
|
|
||||||
"• 订阅命中新资源\n" +
|
|
||||||
"• 下载任务完成\n" +
|
|
||||||
"• 刮削失败告警\n" +
|
|
||||||
"• 系统异常通知"
|
|
||||||
}
|
|
||||||
|
|
||||||
func telegramMgoAdminCommandHelp() string {
|
|
||||||
return "<b>Mgo 管理命令(管理员可用,已注册到命令栏):</b>\n" +
|
|
||||||
"用户:<code>/ucr 用户名 密码 [天数]</code> 创建账号;<code>/uinfo 用户名</code> 查询账号;<code>/rmemby 用户名 confirm</code> 删除账号;<code>/only_rm_record tg:ID|用户名</code> 仅删 Bot 绑定;<code>/renewall 天数 confirm</code> 批量续期。\n" +
|
|
||||||
"审计:<code>/userip 用户名</code> 查用户 IP;<code>/auditip IP</code> 按 IP 审计;<code>/auditdevice 关键词</code> 按终端设备审计;<code>/auditclient 关键词</code> 按客户端审计;<code>/udeviceid 设备ID</code> 按设备指纹审计。\n" +
|
|
||||||
"清理:<code>/syncunbound</code> 检查未绑定账号;<code>/syncgroupm</code> 校验群成员;<code>/check_ex</code> 检查过期账号;<code>/deleted</code> 按保号规则预览清理候选。\n" +
|
|
||||||
"权限:<code>/embyadmin 用户名 on|off</code> 设置管理员;<code>/banall confirm</code>/<code>/unbanall confirm</code> 批量禁用/解禁;<code>/prouser 用户名</code>/<code>/revuser 用户名</code> 管理保护名单;<code>/embylibs_blockall</code>/<code>/embylibs_unblockall</code> 批量禁用/开放媒体库权限。\n" +
|
|
||||||
"运维:<code>/proadmin TelegramID</code>/<code>/revadmin TelegramID</code> 管理 Bot 管理员;<code>/backup_db</code> 备份数据库;<code>/restore_from_db 文件名 confirm</code> 恢复数据库。\n" +
|
|
||||||
"说明:重复别名如 <code>/low_activity</code>、<code>/urm</code> 仍可兼容识别,但不显示在命令栏。"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: "请先绑定账号:<code>/start 用户名 密码</code>"}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<code>/start 用户名 密码</code>"}
|
||||||
|
}
|
||||||
|
status := "未隐藏"
|
||||||
|
if user.HideAdult {
|
||||||
|
status = "已隐藏"
|
||||||
|
}
|
||||||
|
return telegramCommandReply{
|
||||||
|
Text: fmt.Sprintf("<b>MediaStationGo 已绑定</b>\n\n你好 %s,当前账号:<b>%s</b>\n成人目录:<b>%s</b>", name, userNameOrFallback(user), status),
|
||||||
|
Buttons: [][]telegramInlineButton{{{
|
||||||
|
Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult],
|
||||||
|
Data: "adult_toggle",
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hint := "如果没有账号,请联系管理员注册。"
|
||||||
|
if s.openRegEnabled(ctx) {
|
||||||
|
hint = "如果还没有账号,可直接注册:\n<code>/register 用户名 密码</code>\n或:<code>/register 用户名-密码</code>"
|
||||||
|
}
|
||||||
|
return telegramCommandReply{Text: "<b>欢迎使用 MediaStationGo</b>\n\n普通用户请先绑定账号:\n<code>/start 用户名 密码</code>\n或:<code>/start 用户名-密码</code>\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<code>/start 用户名 密码</code>\n或:<code>/start 用户名-密码</code>"}
|
||||||
|
}
|
||||||
|
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("绑定成功:<b>%s</b>\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 已绑定账号:<b>%s</b>,无需重复注册。\n如需切换账号请使用 <code>/start 用户名 密码</code>。", userNameOrFallback(user))}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
username, password := parseStartCredentials(args)
|
||||||
|
if username == "" || password == "" {
|
||||||
|
return telegramCommandReply{Text: "注册格式不正确,请使用:\n<code>/register 用户名 密码</code>\n或:<code>/register 用户名-密码</code>"}
|
||||||
|
}
|
||||||
|
user, _, err := s.auth.Register(ctx, username, password)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrUsernameTaken):
|
||||||
|
return telegramCommandReply{Text: "该用户名已被占用,请换一个;如果是你本人的账号,请改用 <code>/start 用户名 密码</code> 绑定。"}
|
||||||
|
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("账号 <b>%s</b> 注册成功,但自动绑定失败:%s\n请稍后使用 <code>/start %s 密码</code> 重新绑定。", user.Username, err.Error(), user.Username)}
|
||||||
|
}
|
||||||
|
return telegramCommandReply{
|
||||||
|
Text: fmt.Sprintf("注册并绑定成功:<b>%s</b>\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 注册功能当前<b>%s</b>。\n剩余可注册:<b>%d</b> 人。\n\n开启:<code>/registration on 10</code>\n不限:<code>/registration on 0</code>\n关闭:<code>/registration off</code>", 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: "参数无效,请使用 <code>/registration on [名额]</code> 或 <code>/registration off</code>。"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 "<b>MediaStationGo 群组可用命令</b>\n\n" +
|
||||||
|
"<b>/menu</b> — 打开群组自助菜单\n" +
|
||||||
|
"<b>/account</b> — 查看账号状态\n" +
|
||||||
|
"<b>/signin</b> — 签到\n" +
|
||||||
|
"<b>/devices</b> — 查看登录设备\n" +
|
||||||
|
"<b>/kick all|编号</b> — 踢下线设备\n" +
|
||||||
|
"<b>/hideadult on|off</b> — 隐藏或显示成人目录\n\n" +
|
||||||
|
"绑定、注册、兑换、改名、改密等包含敏感信息的操作请私聊 Bot。" +
|
||||||
|
adminHint
|
||||||
|
}
|
||||||
|
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
|
||||||
|
register := ""
|
||||||
|
if s.openRegEnabled(ctx) {
|
||||||
|
register = "<b>/register 用户名 密码</b> — 注册新账号\n"
|
||||||
|
}
|
||||||
|
return "<b>MediaStationGo 用户命令</b>\n\n" +
|
||||||
|
register +
|
||||||
|
"<b>/start 用户名 密码</b> — 绑定账号\n" +
|
||||||
|
"<b>/account</b> — 查看账号状态\n" +
|
||||||
|
"<b>/signin</b> — 签到\n" +
|
||||||
|
"<b>/devices</b> — 查看登录设备\n" +
|
||||||
|
"<b>/kick all|编号</b> — 踢下线设备\n" +
|
||||||
|
"<b>/setname 当前密码 新用户名</b> — 修改用户名\n" +
|
||||||
|
"<b>/setpass 当前密码 新密码</b> — 修改密码\n" +
|
||||||
|
"<b>/redeem 兑换码</b> — 注册或续期兑换\n" +
|
||||||
|
"<b>/hideadult on|off</b> — 隐藏或显示成人目录\n\n" +
|
||||||
|
"系统状态、搜索、下载列表与统计命令仅管理员可用。"
|
||||||
|
}
|
||||||
|
return "<b>MediaStationGo 命令列表</b>\n\n" +
|
||||||
|
"<b>/start</b> — 开始使用\n" +
|
||||||
|
"<b>/help</b> — 帮助信息\n" +
|
||||||
|
"<b>/account</b> / <b>/devices</b> / <b>/kick all|编号</b> — 用户自助设备管理\n" +
|
||||||
|
"<b>/signin</b> / <b>/redeem 兑换码</b> — 签到与兑换\n" +
|
||||||
|
"<b>/setname 当前密码 新用户名</b> / <b>/setpass 当前密码 新密码</b> — 用户自助改名改密\n" +
|
||||||
|
"<b>/register 用户名 密码</b> — 注册新账号(需管理员开启)\n" +
|
||||||
|
"<b>/registration on [名额]|off</b> — 开启/关闭普通用户注册(管理员)\n" +
|
||||||
|
"<b>/capacity</b> / <b>/users</b> — 容量与用户管理(管理员)\n" +
|
||||||
|
"<b>/gencode register|renew 天数 [有效天数]</b> — 生成兑换码(管理员)\n" +
|
||||||
|
"<b>/renew_user 用户名 天数</b> / <b>/delete_user 用户名 confirm</b> — 续期/删除用户(管理员)\n" +
|
||||||
|
"<b>/unbind 用户1 用户2</b> — 批量解绑 Telegram 绑定(管理员)\n" +
|
||||||
|
"<b>/unbind_duplicates</b> / <b>/unbind_inactive 天数</b> — 清理重复/无效绑定或久未登录绑定(管理员)\n" +
|
||||||
|
"<b>/antishare on play=3 login=3 warn=2</b> — 防共享策略(管理员)\n" +
|
||||||
|
"<b>/cleanup run</b> — 预览保号清理候选(管理员)\n" +
|
||||||
|
"<b>/cleanup run confirm</b> — 确认清理候选账号(管理员)\n" +
|
||||||
|
"<b>/cleanup on|off</b> — 保号规则开关(管理员)\n" +
|
||||||
|
"<b>/cleanup_rule list|add|edit|修改|del|enable|disable</b> — Mgo 保号规则(管理员)\n" +
|
||||||
|
"<b>/ban 用户名</b> / <b>/unban 用户名</b> — 禁用/解禁用户(管理员)\n" +
|
||||||
|
"<b>/hideadult on|off</b> — 隐藏/显示当前绑定账号的成人目录\n" +
|
||||||
|
"<b>/status</b> — 系统运行状态\n" +
|
||||||
|
"<b>/search 关键词</b> — 搜索媒体库\n" +
|
||||||
|
"<b>/downloads</b> — 下载列表\n" +
|
||||||
|
"<b>/stats</b> — 媒体库统计\n\n" +
|
||||||
|
telegramMgoAdminCommandHelp() + "\n\n" +
|
||||||
|
"<b>自动推送事件:</b>\n" +
|
||||||
|
"• 订阅命中新资源\n" +
|
||||||
|
"• 下载任务完成\n" +
|
||||||
|
"• 刮削失败告警\n" +
|
||||||
|
"• 系统异常通知"
|
||||||
|
}
|
||||||
|
|
||||||
|
func telegramMgoAdminCommandHelp() string {
|
||||||
|
return "<b>Mgo 管理命令(管理员可用,已注册到命令栏):</b>\n" +
|
||||||
|
"用户:<code>/ucr 用户名 密码 [天数]</code> 创建账号;<code>/uinfo 用户名</code> 查询账号;<code>/rmemby 用户名 confirm</code> 删除账号;<code>/only_rm_record tg:ID|用户名</code> 仅删 Bot 绑定;<code>/renewall 天数 confirm</code> 批量续期。\n" +
|
||||||
|
"审计:<code>/userip 用户名</code> 查用户 IP;<code>/auditip IP</code> 按 IP 审计;<code>/auditdevice 关键词</code> 按终端设备审计;<code>/auditclient 关键词</code> 按客户端审计;<code>/udeviceid 设备ID</code> 按设备指纹审计。\n" +
|
||||||
|
"清理:<code>/syncunbound</code> 检查未绑定账号;<code>/syncgroupm</code> 校验群成员;<code>/check_ex</code> 检查过期账号;<code>/deleted</code> 按保号规则预览清理候选。\n" +
|
||||||
|
"权限:<code>/embyadmin 用户名 on|off</code> 设置管理员;<code>/banall confirm</code>/<code>/unbanall confirm</code> 批量禁用/解禁;<code>/prouser 用户名</code>/<code>/revuser 用户名</code> 管理保护名单;<code>/embylibs_blockall</code>/<code>/embylibs_unblockall</code> 批量禁用/开放媒体库权限。\n" +
|
||||||
|
"运维:<code>/proadmin TelegramID</code>/<code>/revadmin TelegramID</code> 管理 Bot 管理员;<code>/backup_db</code> 备份数据库;<code>/restore_from_db 文件名 confirm</code> 恢复数据库。\n" +
|
||||||
|
"说明:重复别名如 <code>/low_activity</code>、<code>/urm</code> 仍可兼容识别,但不显示在命令栏。"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: "请先绑定账号:<code>/start 用户名 密码</code>"}
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user