From f0bc0661340536f1d9b65fc3b1f7a5ea9803e367 Mon Sep 17 00:00:00 2001 From: soldosluka857 Date: Sat, 30 May 2026 08:48:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(telegram):=20admin-toggleable=20Bot=20regi?= =?UTF-8?q?stration=20+=20remove=20duplicate=20=E6=9C=80=E8=BF=91=E5=85=A5?= =?UTF-8?q?=E5=BA=93=20on=20=E8=BF=90=E8=A1=8C=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add telegram.registration_enabled setting (default off); admins toggle via Settings page or /registration on|off bot command. - Add /register (/reg /signup) bot command: when enabled, regular users can create a MediaStation account and auto-bind their Telegram. Reuses AuthService (username-taken / user-limit handling) and keeps new accounts as regular users. - Regular users still limited to bind / adult-toggle / start / help; all other commands remain admin-only. - Update /start and /help text to surface registration when enabled. - Remove the duplicate 最近入库 module from the 运行状态 (stats) page; the homepage already shows recently added. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/service/service.go | 5 +- internal/service/telegram_bot.go | 116 ++++++++++++++++++++- internal/service/telegram_bot_user_test.go | 58 ++++++++++- web/src/pages/SettingsPage.tsx | 14 +++ web/src/pages/StatsPage.tsx | 21 +--- 5 files changed, 186 insertions(+), 28 deletions(-) diff --git a/internal/service/service.go b/internal/service/service.go index 10583bb..d3bc2c1 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -109,7 +109,6 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont backup := NewBackupService(cfg, log, repos.DB) notifier := NewNotifierService(log, repos) notifyChannels := NewNotifyChannelService(log, repos) - telegramBot := NewTelegramBotService(log, repos, crypto) playProfiles := NewPlayProfileService(log, repos) permissions := NewPermissionService(log, repos) storageCfg := NewStorageConfigService(log, repos, crypto) @@ -120,6 +119,8 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont // 初始化认证相关服务 tokenSvc := NewTokenService(cfg, log, repos) + authSvc := NewAuthService(cfg, log, repos, tokenSvc, permissions) + telegramBot := NewTelegramBotService(log, repos, crypto, authSvc) apiConfigSvc := NewApiConfigService(cfg, log, repos, crypto) downloadMgr := NewDownloadManager(log, repos, crypto) notifySvc := NewNotifyService(log, repos, crypto) @@ -159,7 +160,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont Repo: repos, WSHub: hub, SSEHub: sseHub, - Auth: NewAuthService(cfg, log, repos, tokenSvc, permissions), + Auth: authSvc, Media: NewMediaService(cfg, log, repos), Scan: scanner, Stream: NewStreamService(cfg, log, repos, transcoder), diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index da92bf1..8db150a 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -75,21 +75,41 @@ type TelegramBotService struct { log *zap.Logger repo *repository.Container crypto *CryptoService + auth *AuthService pollingMu sync.Mutex pollingCancel map[string]context.CancelFunc // bot_token -> cancel } // NewTelegramBotService 创建 Telegram Bot 服务。 -func NewTelegramBotService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *TelegramBotService { +func NewTelegramBotService(log *zap.Logger, repo *repository.Container, crypto *CryptoService, auth *AuthService) *TelegramBotService { return &TelegramBotService{ log: log, repo: repo, crypto: crypto, + auth: auth, pollingCancel: make(map[string]context.CancelFunc), } } +// TelegramRegistrationSettingKey 控制普通用户是否可以通过 Bot 注册新账号。 +// 默认关闭,只有管理员在系统设置 / Bot 管理命令中显式开启后才允许注册。 +const TelegramRegistrationSettingKey = "telegram.registration_enabled" + +// registrationEnabled 读取注册开关;默认关闭。 +func (s *TelegramBotService) registrationEnabled(ctx context.Context) bool { + v, err := s.repo.Setting.Get(ctx, TelegramRegistrationSettingKey) + if err != nil { + return false + } + return parseBoolSetting(v, false) +} + +// setRegistrationEnabled 持久化注册开关。 +func (s *TelegramBotService) setRegistrationEnabled(ctx context.Context, enabled bool) error { + return s.repo.Setting.Set(ctx, TelegramRegistrationSettingKey, strconv.FormatBool(enabled)) +} + // HandleWebhook 处理 Telegram 推送的 Webhook/Polling 消息。 func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) error { var update TelegramUpdate @@ -169,6 +189,13 @@ func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model. return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil case "/hideadult", "/hide_adult", "/adult": return s.cmdHideAdult(ctx, msg, args), nil + case "/register", "/reg", "/signup": + return s.cmdRegister(ctx, channel, msg, args), nil + case "/registration", "/reg_switch": + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil + } + return s.cmdRegistrationToggle(ctx, args), nil case "/status": if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { return telegramCommandReply{Text: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。"}, nil @@ -215,7 +242,9 @@ func telegramCommandName(text string) string { func telegramSupportedCommand(cmd string) bool { switch cmd { - case "/start", "/help", "/hideadult", "/hide_adult", "/adult", "/status", "/search", "/downloads", "/stats": + case "/start", "/help", "/hideadult", "/hide_adult", "/adult", + "/register", "/reg", "/signup", "/registration", "/reg_switch", + "/status", "/search", "/downloads", "/stats": return true default: return false @@ -247,7 +276,11 @@ func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, }}}, } } - return telegramCommandReply{Text: "欢迎使用 MediaStationGo\n\n普通用户请先绑定账号:\n/start 用户名 密码\n或:/start 用户名-密码\n\n如果没有账号,请联系管理员注册。"} + hint := "如果没有账号,请联系管理员注册。" + if s.registrationEnabled(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 !s.telegramUserCanBind(ctx, channel, msg.From.ID) { @@ -279,11 +312,86 @@ func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, } } +// cmdRegister 处理 /register 命令:在管理员开启注册后,普通用户可通过 Bot +// 注册一个新的媒体中心账号,并自动绑定到当前 Telegram 账号。 +func (s *TelegramBotService) cmdRegister(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply { + if !s.registrationEnabled(ctx) { + return telegramCommandReply{Text: "注册功能未开放,请联系管理员开启后再试。"} + } + if s.auth == nil { + return telegramCommandReply{Text: "注册功能暂不可用,请联系管理员。"} + } + if channel == nil { + channel = s.findChannelForMessage(ctx, msg) + } + if !s.telegramUserCanBind(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "当前 Telegram 账号不在管理员配置的绑定群组/频道中,无法注册账号。请先加入管理员配置的群组或频道;如果尚未配置,请联系管理员。"} + } + 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()} + } + } + 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 处理管理员的 /registration on|off|status 命令。 +func (s *TelegramBotService) cmdRegistrationToggle(ctx context.Context, args []string) telegramCommandReply { + current := s.registrationEnabled(ctx) + if len(args) == 0 || strings.EqualFold(strings.TrimSpace(args[0]), "status") { + state := map[bool]string{true: "已开启", false: "已关闭"}[current] + return telegramCommandReply{Text: fmt.Sprintf("普通用户 Bot 注册功能当前%s。\n\n开启:/registration on\n关闭:/registration off", state)} + } + var next bool + switch strings.ToLower(strings.TrimSpace(args[0])) { + case "on", "true", "1", "open", "enable", "enabled", "开启", "打开", "开": + next = true + case "off", "false", "0", "close", "disable", "disabled", "关闭", "关": + next = false + default: + return telegramCommandReply{Text: "参数无效,请使用 /registration on 或 /registration off。"} + } + if err := s.setRegistrationEnabled(ctx, next); err != nil { + return telegramCommandReply{Text: "更新失败:" + err.Error()} + } + state := map[bool]string{true: "已开启", false: "已关闭"}[next] + return telegramCommandReply{Text: fmt.Sprintf("普通用户 Bot 注册功能%s。此设置与系统设置页同步。", state)} +} + // cmdHelp 处理 /help 命令。 func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) string { channel := s.findChannelForMessage(ctx, msg) if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + register := "" + if s.registrationEnabled(ctx) { + register = "/register 用户名 密码 — 注册新账号\n" + } return "MediaStationGo 用户命令\n\n" + + register + "/start 用户名 密码 — 绑定账号\n" + "/hideadult on|off — 隐藏或显示成人目录\n\n" + "系统状态、搜索、下载列表与统计命令仅管理员可用。" @@ -291,6 +399,8 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) return "MediaStationGo 命令列表\n\n" + "/start — 开始使用\n" + "/help — 帮助信息\n" + + "/register 用户名 密码 — 注册新账号(需管理员开启)\n" + + "/registration on|off — 开启/关闭普通用户注册(管理员)\n" + "/hideadult on|off — 隐藏/显示当前绑定账号的成人目录\n" + "/status — 系统运行状态\n" + "/search 关键词 — 搜索媒体库\n" + diff --git a/internal/service/telegram_bot_user_test.go b/internal/service/telegram_bot_user_test.go index 78fab8d..8925f14 100644 --- a/internal/service/telegram_bot_user_test.go +++ b/internal/service/telegram_bot_user_test.go @@ -59,7 +59,7 @@ func TestTelegramCallbackTogglesAdultVisibility(t *testing.T) { t.Fatalf("load user before toggle: %v", err) } - bot := NewTelegramBotService(zap.NewNop(), repos, nil) + bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) update, _ := json.Marshal(TelegramUpdate{ UpdateID: 1, CallbackQuery: &TelegramCallbackQuery{ @@ -81,8 +81,60 @@ func TestTelegramCallbackTogglesAdultVisibility(t *testing.T) { } } +func TestTelegramRegisterRespectsAdminToggle(t *testing.T) { + ctx := t.Context() + repos, auth, _, _ := newAuthTestServices(t) + if err := repos.DB.AutoMigrate(&model.Setting{}, &model.NotifyChannel{}); err != nil { + t.Fatalf("migrate: %v", err) + } + // 预置一个管理员,确保通过 Bot 注册的用户是普通角色而非首个管理员。 + if _, _, err := auth.Register(ctx, "rootadmin", "admin-pass"); err != nil { + t.Fatalf("seed admin: %v", err) + } + + bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) + // 把注册者放进 admin_user_ids,即可让 telegramUserCanBind 通过(私聊场景, + // 无需走 getChatMember 网络校验);注册流程本身不依赖角色。 + cfgJSON, _ := json.Marshal(map[string]string{"admin_user_ids": "999"}) + channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfgJSON)} + + msg := &TelegramMessage{From: TelegramUser{ID: 999, Username: "newbie", FirstName: "Newbie"}, Chat: TelegramChat{ID: 999, Type: "private"}} + + // 默认关闭:拒绝且不创建用户。 + if reply := bot.cmdRegister(ctx, channel, msg, []string{"newbie", "secret-pass"}); !strings.Contains(reply.Text, "未开放") { + t.Fatalf("registration disabled by default, got %q", reply.Text) + } + if u, _ := repos.User.FindByUsername(ctx, "newbie"); u != nil { + t.Fatal("no user should be created while registration disabled") + } + + // 管理员开启后注册成功并自动绑定。 + if err := bot.setRegistrationEnabled(ctx, true); err != nil { + t.Fatalf("enable registration: %v", err) + } + reply := bot.cmdRegister(ctx, channel, msg, []string{"newbie", "secret-pass"}) + if !strings.Contains(reply.Text, "注册并绑定成功") { + t.Fatalf("expected success reply, got %q", reply.Text) + } + created, err := repos.User.FindByUsername(ctx, "newbie") + if err != nil || created == nil { + t.Fatalf("user should be created after enabling: %v", err) + } + if created.Role != "user" { + t.Fatalf("bot-registered account should be a regular user, got role %q", created.Role) + } + if binding := bot.telegramBinding(ctx, 999); binding == nil || binding.UserID != created.ID { + t.Fatalf("telegram should be bound to the newly registered user") + } + + // 重复注册:已绑定 → 提示无需重复注册。 + if reply := bot.cmdRegister(ctx, channel, msg, []string{"another", "pass-2"}); !strings.Contains(reply.Text, "无需重复注册") { + t.Fatalf("expected already-bound reply, got %q", reply.Text) + } +} + func TestTelegramStartClearsStaleUserBinding(t *testing.T) { - repos, _, _, _ := newAuthTestServices(t) + repos, auth, _, _ := newAuthTestServices(t) if err := repos.DB.Create(&model.TelegramBinding{ TelegramUserID: 20001, TelegramName: "@viewer", @@ -91,7 +143,7 @@ func TestTelegramStartClearsStaleUserBinding(t *testing.T) { }).Error; err != nil { t.Fatalf("create binding: %v", err) } - bot := NewTelegramBotService(zap.NewNop(), repos, nil) + bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) reply := bot.cmdStart(t.Context(), &TelegramMessage{ From: TelegramUser{ID: 20001, Username: "viewer", FirstName: "Viewer"}, diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index bf7b6d2..29ab06c 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -260,6 +260,20 @@ const GROUPS: SettingGroup[] = [ }, ], }, + { + key: 'telegram', + label: 'Telegram Bot', + description: '机器人注册与普通用户权限', + items: [ + { + key: 'telegram.registration_enabled', + label: '允许普通用户通过 Bot 注册', + type: 'toggle', + hint: '默认关闭。开启后用户可在 Telegram 中用 /register 用户名 密码 注册账号并自动绑定;关闭后注册被拒绝。普通用户始终只能绑定账号、开关成人目录显隐及使用 /start、/help。', + defaultValue: 'false', + }, + ], + }, // qBittorrent 配置已迁移到独立的「下载器」页面(侧边栏 → 下载器), // 该页面支持多客户端 + 连接测试。这里不再重复暴露入口,避免与 // /api/admin/download/clients 写入的数据来源冲突。 diff --git a/web/src/pages/StatsPage.tsx b/web/src/pages/StatsPage.tsx index 684c6ce..1426af3 100644 --- a/web/src/pages/StatsPage.tsx +++ b/web/src/pages/StatsPage.tsx @@ -2,9 +2,7 @@ import { useEffect, useState } from 'react' import { Activity, Cpu, Database, Film, HardDrive, Radio, Users } from 'lucide-react' import { statsAPI } from '../api/stats' -import { MediaCard } from '../components/MediaCard' import type { Hardware, StatsSnapshot } from '../types' -import { groupSeries } from '../utils/groupSeries' // fmtBytes is a tiny helper shared by the dashboard cards. function fmtBytes(n: number): string { @@ -89,7 +87,6 @@ export function StatsPage() { live.disk_total > 0 ? (live.disk_used / live.disk_total) * 100 : 0 - const recentlyAddedCards = groupSeries(snap.recently_added) return (
@@ -164,25 +161,9 @@ export function StatsPage() { label="统计刷新" value={new Date(snap.generated_at).toLocaleString()} /> - +
- - {recentlyAddedCards.length > 0 && ( -
-

最近入库

-
- {recentlyAddedCards.map((s) => ( - 1 ? `/library/${s.rep.library_id}?series=${encodeURIComponent(s.key)}` : undefined} - /> - ))} -
-
- )} ) }