mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
docs: simplify deployment docs and restrict telegram group commands
This commit is contained in:
+265
-883
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,14 @@
|
||||
# MediaStationGo 新手部署 .env 示例
|
||||
# 只改下面两个路径即可;必须是宿主机/NAS真实路径。
|
||||
# MediaStationGo 可选 .env 示例
|
||||
#
|
||||
# 新手不建议使用 .env;请直接改 docker-compose.yml,更直观。
|
||||
# 这个文件只给进阶用户复用多台机器配置时参考。
|
||||
#
|
||||
# 如果你确实要用 .env,下面两个路径必须是宿主机/NAS真实路径。
|
||||
|
||||
MEDIASTATION_MEDIA_DIR=/your-nas/media
|
||||
MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
|
||||
|
||||
# 可选:固定镜像版本。不写则使用 latest。
|
||||
# 可选:固定镜像版本。不写则使用 compose 里的 latest。
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.32
|
||||
|
||||
# 可选:修改访问端口。不写则使用 18080。
|
||||
|
||||
+60
-42
@@ -1,53 +1,68 @@
|
||||
# MediaStationGo 极简 Docker Compose 部署文件
|
||||
# MediaStationGo 最简单 Docker Compose 部署文件
|
||||
#
|
||||
# 新手推荐:直接在本文件里填写 NAS/服务器真实路径,不必先创建 .env。
|
||||
# 需要改两组位置:
|
||||
# 1. volumes 里媒体库和下载目录左侧的宿主机路径。
|
||||
# 2. environment 里 MEDIASTATION_MEDIA_DIR / MEDIASTATION_DOWNLOAD_DIR 的同名真实路径。
|
||||
# 新手建议:
|
||||
# 1. 不用 .env。
|
||||
# 2. 直接改本文件。
|
||||
# 3. 第一次可以不改路径,先用当前目录下的 ./media 和 ./downloads 体验。
|
||||
#
|
||||
# 示例:
|
||||
# - /vol1/1000/Docker/moviepilot-v2/media:/media:ro
|
||||
# - /vol1/1000/qBittorrent/downloads:/downloads
|
||||
# MEDIASTATION_MEDIA_DIR: /vol1/1000/Docker/moviepilot-v2/media
|
||||
# MEDIASTATION_DOWNLOAD_DIR: /vol1/1000/qBittorrent/downloads
|
||||
# 启动:
|
||||
# docker compose up -d
|
||||
#
|
||||
# .env 是可选高级写法,适合多环境复用;README 后半部分有说明。
|
||||
# 访问:
|
||||
# http://服务器IP:18080
|
||||
#
|
||||
# 启动:docker compose up -d
|
||||
# 访问:http://<服务器IP>:18080
|
||||
# 默认账号:admin / admin123
|
||||
# 默认账号:
|
||||
# admin / admin123
|
||||
|
||||
services:
|
||||
mediastation-go:
|
||||
image: ghcr.io/shukebta/mediastation-go:${MEDIASTATION_IMAGE_TAG:-latest}
|
||||
image: ghcr.io/shukebta/mediastation-go:latest
|
||||
container_name: mediastation-go
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
pull_policy: missing
|
||||
|
||||
# 浏览器访问端口。
|
||||
# 如果 18080 被占用,可以改成 "19011:8080" 之类。
|
||||
ports:
|
||||
- "${MEDIASTATION_HTTP_PORT:-18080}:8080"
|
||||
- "18080:8080"
|
||||
|
||||
# 让容器可以用 http://host.docker.internal:端口 访问宿主机上的 qBittorrent 等服务。
|
||||
# 让容器可以访问宿主机上的 qBittorrent。
|
||||
# qB 地址通常可填:http://host.docker.internal:8085
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
# 程序数据与缓存:放在部署目录下,方便备份。
|
||||
- ${MEDIASTATION_DATA_DIR:-./data}:/data
|
||||
- ${MEDIASTATION_CACHE_DIR:-./cache}:/cache
|
||||
# 程序数据:数据库、账号、设置都在这里。升级前主要备份它。
|
||||
- ./data:/data
|
||||
|
||||
# 媒体库与下载目录:左边是宿主机/NAS真实路径,右边是容器内路径。
|
||||
# 新手部署时推荐直接把左边改成真实绝对路径,不要写 ./vol1/...。
|
||||
# Web 页面里添加媒体库请填写 /media/电影、/media/电视剧 等容器路径。
|
||||
- ${MEDIASTATION_MEDIA_DIR:-./media}:/media:ro
|
||||
- ${MEDIASTATION_DOWNLOAD_DIR:-./downloads}:/downloads
|
||||
# 缓存目录:海报缓存、临时文件等。通常不用备份。
|
||||
- ./cache:/cache
|
||||
|
||||
# 媒体库目录。
|
||||
# 新手可先把影片放到当前目录的 ./media。
|
||||
# NAS 用户把左边改成真实路径,例如:
|
||||
# - /vol1/1000/Media:/media:ro
|
||||
# Windows Docker Desktop 示例:
|
||||
# - D:/Media:/media:ro
|
||||
- ./media:/media:ro
|
||||
|
||||
# 下载目录。
|
||||
# qB 下载目录、手动整理、自动整理会经常用到。
|
||||
# NAS 示例:
|
||||
# - /vol1/1000/Downloads:/downloads
|
||||
# Windows Docker Desktop 示例:
|
||||
# - D:/Downloads:/downloads
|
||||
- ./downloads:/downloads
|
||||
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
PUID: ${PUID:-1000}
|
||||
PGID: ${PGID:-1000}
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
# Linux/NAS 用户权限。一般 1000 就可以。
|
||||
# 如果写入文件权限不对,再改成宿主机实际用户的 uid/gid。
|
||||
PUID: "1000"
|
||||
PGID: "1000"
|
||||
|
||||
# 程序基础配置,通常不用改。
|
||||
MEDIASTATION_APP_HOST: 0.0.0.0
|
||||
MEDIASTATION_APP_PORT: 8080
|
||||
MEDIASTATION_APP_WEB_DIR: /app/web/dist
|
||||
@@ -55,20 +70,24 @@ services:
|
||||
MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db
|
||||
MEDIASTATION_CACHE_CACHE_DIR: /cache
|
||||
|
||||
# 路径提示:这里要和 volumes 左侧真实路径保持一致。
|
||||
# 用于把宿主机路径自动换算成容器路径,避免误填 NAS 原始路径时报不可访问。
|
||||
MEDIASTATION_MEDIA_DIR: ${MEDIASTATION_MEDIA_DIR:-./media}
|
||||
# 路径换算配置。
|
||||
# 如果上面 volumes 的 ./media 改成 /vol1/1000/Media,
|
||||
# 这里也要改成同一个宿主机真实路径。
|
||||
MEDIASTATION_MEDIA_DIR: ./media
|
||||
MEDIASTATION_MEDIA_CONTAINER_DIR: /media
|
||||
MEDIASTATION_DOWNLOAD_DIR: ${MEDIASTATION_DOWNLOAD_DIR:-./downloads}
|
||||
|
||||
# 如果上面 volumes 的 ./downloads 改成 /vol1/1000/Downloads,
|
||||
# 这里也要改成同一个宿主机真实路径。
|
||||
MEDIASTATION_DOWNLOAD_DIR: ./downloads
|
||||
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||
|
||||
# NAS 友好的低负载转码默认值;可在系统设置里继续调整。
|
||||
MEDIASTATION_TRANSCODER_ENABLED: ${MEDIASTATION_TRANSCODER_ENABLED:-true}
|
||||
MEDIASTATION_TRANSCODER_HARDWARE_ACCEL: ${MEDIASTATION_TRANSCODER_HARDWARE_ACCEL:-false}
|
||||
MEDIASTATION_TRANSCODER_REALTIME: ${MEDIASTATION_TRANSCODER_REALTIME:-true}
|
||||
MEDIASTATION_TRANSCODER_THREADS: ${MEDIASTATION_TRANSCODER_THREADS:-2}
|
||||
MEDIASTATION_TRANSCODER_MAX_CONCURRENT: ${MEDIASTATION_TRANSCODER_MAX_CONCURRENT:-1}
|
||||
MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS: ${MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS:-120}
|
||||
# NAS 友好的低负载默认值。
|
||||
MEDIASTATION_TRANSCODER_ENABLED: "true"
|
||||
MEDIASTATION_TRANSCODER_HARDWARE_ACCEL: "false"
|
||||
MEDIASTATION_TRANSCODER_REALTIME: "true"
|
||||
MEDIASTATION_TRANSCODER_THREADS: "2"
|
||||
MEDIASTATION_TRANSCODER_MAX_CONCURRENT: "1"
|
||||
MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||
@@ -77,8 +96,7 @@ services:
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# 限制容器日志体积:默认 json-file 驱动不设上限,
|
||||
# 长期运行会持续吃宿主机磁盘与 IO。
|
||||
# 限制 Docker 日志大小,避免长期运行把磁盘写满。
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
|
||||
@@ -96,14 +96,20 @@ func TestTelegramTargetChatIDsUsesLegacyPrivateChatID(t *testing.T) {
|
||||
|
||||
func TestRegisterTelegramBotCommands(t *testing.T) {
|
||||
var gotPath string
|
||||
var payload struct {
|
||||
var payloads []struct {
|
||||
Commands []telegramBotCommand `json:"commands"`
|
||||
Scope map[string]any `json:"scope"`
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
var payload struct {
|
||||
Commands []telegramBotCommand `json:"commands"`
|
||||
Scope map[string]any `json:"scope"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
payloads = append(payloads, payload)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
@@ -118,9 +124,55 @@ func TestRegisterTelegramBotCommands(t *testing.T) {
|
||||
if gotPath != "/bot123456:ABC/setMyCommands" {
|
||||
t.Fatalf("path = %q", gotPath)
|
||||
}
|
||||
if len(payload.Commands) == 0 || payload.Commands[0].Command != "start" {
|
||||
t.Fatalf("commands not registered: %#v", payload.Commands)
|
||||
if len(payloads) < 3 {
|
||||
t.Fatalf("expected default/private/group command registrations, got %d", len(payloads))
|
||||
}
|
||||
if len(payloads[0].Commands) == 0 || payloads[0].Commands[0].Command != "start" {
|
||||
t.Fatalf("commands not registered: %#v", payloads[0].Commands)
|
||||
}
|
||||
var groupCommands []telegramBotCommand
|
||||
for _, payload := range payloads {
|
||||
if payload.Scope["type"] == "all_group_chats" {
|
||||
groupCommands = payload.Commands
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(groupCommands) == 0 {
|
||||
t.Fatal("group command scope was not registered")
|
||||
}
|
||||
for _, command := range groupCommands {
|
||||
if command.Command == "users" || command.Command == "status" || command.Command == "cleanup" || command.Command == "register" || command.Command == "redeem" {
|
||||
t.Fatalf("group commands must not expose private/admin command %q", command.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramCommandMenusSeparateGroupAndAdminCommands(t *testing.T) {
|
||||
groupNames := telegramCommandNames(telegramGroupBotCommandMenu())
|
||||
for _, forbidden := range []string{"status", "search", "downloads", "stats", "users", "cleanup", "cleanup_rule", "register", "redeem"} {
|
||||
if groupNames[forbidden] {
|
||||
t.Fatalf("group menu should not expose %s", forbidden)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{"start", "menu", "help", "account", "signin", "devices", "kick", "hideadult"} {
|
||||
if !groupNames[required] {
|
||||
t.Fatalf("group menu should include %s", required)
|
||||
}
|
||||
}
|
||||
adminNames := telegramCommandNames(telegramAdminBotCommandMenu())
|
||||
for _, required := range []string{"users", "status", "cleanup_rule"} {
|
||||
if !adminNames[required] {
|
||||
t.Fatalf("admin menu should include %s", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func telegramCommandNames(commands []telegramBotCommand) map[string]bool {
|
||||
names := make(map[string]bool, len(commands))
|
||||
for _, command := range commands {
|
||||
names[command.Command] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestTelegramProxyCandidatesDefaultLocalFallbacks(t *testing.T) {
|
||||
|
||||
@@ -251,12 +251,31 @@ func telegramCommandName(text string) string {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func telegramIsGroupChat(chatType string) bool {
|
||||
return chatType != "" && chatType != "private"
|
||||
}
|
||||
|
||||
func telegramGroupPrivateAdminHint() string {
|
||||
return "管理命令请私聊 Bot 使用 <code>/menu</code> 或对应管理员命令,避免在群组公开管理面板。"
|
||||
}
|
||||
|
||||
func telegramGroupPrivateUserHint(action string) string {
|
||||
action = strings.TrimSpace(action)
|
||||
if action == "" {
|
||||
action = "此操作"
|
||||
}
|
||||
return action + "包含账号凭据或敏感信息,请私聊 Bot 操作;群组内仅开放账号状态、签到、设备与成人目录开关。"
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -420,6 +439,21 @@ func (s *TelegramBotService) cmdRegistrationToggle(ctx context.Context, args []s
|
||||
// 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) {
|
||||
|
||||
@@ -134,6 +134,74 @@ func TestTelegramRegisterRespectsAdminToggle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramGroupHidesAdminPanelFromRegularUsers(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
_, bot := newBotTestService(t)
|
||||
channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","admin_user_ids":"9001"}`}
|
||||
msg := &TelegramMessage{
|
||||
From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"},
|
||||
Chat: TelegramChat{ID: -100123, Type: "supergroup"},
|
||||
}
|
||||
|
||||
menu := bot.mainMenu(ctx, channel, msg)
|
||||
if strings.Contains(menu.Text, "管理员") || telegramReplyHasButtonPrefix(menu, "adm_") {
|
||||
t.Fatalf("regular group user must not see admin panel: text=%q buttons=%#v", menu.Text, menu.Buttons)
|
||||
}
|
||||
|
||||
reply, err := bot.executeCommand(ctx, channel, msg, "/users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply.Text != "" || len(reply.Buttons) != 0 {
|
||||
t.Fatalf("regular group user admin command should be ignored, got %#v", reply)
|
||||
}
|
||||
|
||||
reply, err = bot.executeCommand(ctx, channel, msg, "/start viewer secret-pass")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "请私聊 Bot") {
|
||||
t.Fatalf("group credential command should point to private chat, got %q", reply.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramGroupAdminMenuDoesNotExposeButtonsInGroup(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
_, bot := newBotTestService(t)
|
||||
channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","admin_user_ids":"9001"}`}
|
||||
msg := &TelegramMessage{
|
||||
From: TelegramUser{ID: 9001, Username: "admin", FirstName: "Admin"},
|
||||
Chat: TelegramChat{ID: -100123, Type: "group"},
|
||||
}
|
||||
|
||||
menu := bot.mainMenu(ctx, channel, msg)
|
||||
if telegramReplyHasButtonPrefix(menu, "adm_") {
|
||||
t.Fatalf("admin group menu must not expose admin buttons publicly: %#v", menu.Buttons)
|
||||
}
|
||||
if !strings.Contains(menu.Text, "请私聊 Bot") {
|
||||
t.Fatalf("admin group menu should tell admins to use private chat, got %q", menu.Text)
|
||||
}
|
||||
|
||||
reply, handled := bot.handleMenuCallback(ctx, channel, msg, "adm_users")
|
||||
if !handled {
|
||||
t.Fatal("admin callback should be handled")
|
||||
}
|
||||
if !strings.Contains(reply.Text, "请私聊 Bot") || telegramReplyHasButtonPrefix(reply, "adm_") {
|
||||
t.Fatalf("group admin callback should not render admin panel publicly: %#v", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func telegramReplyHasButtonPrefix(reply telegramCommandReply, prefix string) bool {
|
||||
for _, row := range reply.Buttons {
|
||||
for _, button := range row {
|
||||
if strings.HasPrefix(button.Data, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestTelegramStartClearsStaleUserBinding(t *testing.T) {
|
||||
repos, auth, _, _ := newAuthTestServices(t)
|
||||
if err := repos.DB.Create(&model.TelegramBinding{
|
||||
|
||||
@@ -16,31 +16,32 @@ type telegramCommandDefinition struct {
|
||||
Aliases []string
|
||||
AdminOnly bool
|
||||
AdminOnlyText string
|
||||
GroupAllowed bool
|
||||
Handle telegramCommandHandler
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) []telegramCommandDefinition {
|
||||
adminOnly := "此命令仅管理员可用。"
|
||||
return []telegramCommandDefinition{
|
||||
{Aliases: []string{"/start"}, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
{Aliases: []string{"/start"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
if len(args) == 0 {
|
||||
return s.mainMenu(ctx, channel, msg), nil
|
||||
}
|
||||
return s.cmdStart(ctx, msg, args), nil
|
||||
}},
|
||||
{Aliases: []string{"/menu"}, Handle: func(args []string) (telegramCommandReply, error) { return s.mainMenu(ctx, channel, msg), nil }},
|
||||
{Aliases: []string{"/cancel"}, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
{Aliases: []string{"/menu"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.mainMenu(ctx, channel, msg), nil }},
|
||||
{Aliases: []string{"/cancel"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
s.takePending(int64(msg.From.ID))
|
||||
return telegramCommandReply{Text: "已取消当前操作。"}, nil
|
||||
}},
|
||||
{Aliases: []string{"/help"}, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
{Aliases: []string{"/help"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil
|
||||
}},
|
||||
{Aliases: []string{"/hideadult", "/hide_adult", "/adult"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdHideAdult(ctx, msg, args), nil }},
|
||||
{Aliases: []string{"/account", "/me"}, Handle: func(args []string) (telegramCommandReply, error) { return s.replyAccount(ctx, msg), nil }},
|
||||
{Aliases: []string{"/signin", "/checkin"}, Handle: func(args []string) (telegramCommandReply, error) { return s.replySignIn(ctx, msg), nil }},
|
||||
{Aliases: []string{"/devices"}, Handle: func(args []string) (telegramCommandReply, error) { return s.replyDevices(ctx, msg), nil }},
|
||||
{Aliases: []string{"/kick"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdKick(ctx, msg, args), nil }},
|
||||
{Aliases: []string{"/hideadult", "/hide_adult", "/adult"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdHideAdult(ctx, msg, args), nil }},
|
||||
{Aliases: []string{"/account", "/me"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replyAccount(ctx, msg), nil }},
|
||||
{Aliases: []string{"/signin", "/checkin"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replySignIn(ctx, msg), nil }},
|
||||
{Aliases: []string{"/devices"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replyDevices(ctx, msg), nil }},
|
||||
{Aliases: []string{"/kick"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdKick(ctx, msg, args), nil }},
|
||||
{Aliases: []string{"/setname", "/rename"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetName(ctx, msg, args), nil }},
|
||||
{Aliases: []string{"/setpass", "/passwd", "/password"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetPass(ctx, msg, args), nil }},
|
||||
{Aliases: []string{"/redeem"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRedeem(ctx, channel, msg, args), nil }},
|
||||
@@ -102,6 +103,12 @@ func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.
|
||||
if !ok {
|
||||
return telegramCommandReply{Text: fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd)}, nil
|
||||
}
|
||||
if telegramIsGroupChat(msg.Chat.Type) && !def.GroupAllowed {
|
||||
if def.AdminOnly && s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
|
||||
return telegramCommandReply{Text: telegramGroupPrivateAdminHint()}, nil
|
||||
}
|
||||
return telegramCommandReply{}, nil
|
||||
}
|
||||
if def.AdminOnly && !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
|
||||
return telegramCommandReply{Text: def.AdminOnlyText}, nil
|
||||
}
|
||||
@@ -133,6 +140,10 @@ type telegramBotCommand struct {
|
||||
}
|
||||
|
||||
func telegramBotCommandMenu() []telegramBotCommand {
|
||||
return telegramPrivateBotCommandMenu()
|
||||
}
|
||||
|
||||
func telegramPrivateBotCommandMenu() []telegramBotCommand {
|
||||
return []telegramBotCommand{
|
||||
{Command: "start", Description: "绑定账号或打开主菜单"},
|
||||
{Command: "menu", Description: "打开功能菜单"},
|
||||
@@ -144,22 +155,62 @@ func telegramBotCommandMenu() []telegramBotCommand {
|
||||
{Command: "hideadult", Description: "隐藏/显示成人媒体库"},
|
||||
{Command: "redeem", Description: "兑换注册码或续期码"},
|
||||
{Command: "register", Description: "注册新账号"},
|
||||
{Command: "status", Description: "系统运行状态(管理员)"},
|
||||
{Command: "search", Description: "搜索媒体库(管理员)"},
|
||||
{Command: "downloads", Description: "下载列表(管理员)"},
|
||||
{Command: "stats", Description: "媒体库统计(管理员)"},
|
||||
{Command: "users", Description: "用户管理(管理员)"},
|
||||
{Command: "cleanup", Description: "删号规则巡检(管理员)"},
|
||||
{Command: "cleanup_rule", Description: "保号规则管理(管理员)"},
|
||||
}
|
||||
}
|
||||
|
||||
func telegramGroupBotCommandMenu() []telegramBotCommand {
|
||||
return []telegramBotCommand{
|
||||
{Command: "start", Description: "打开群组自助菜单"},
|
||||
{Command: "menu", Description: "打开群组自助菜单"},
|
||||
{Command: "help", Description: "查看群组可用命令"},
|
||||
{Command: "account", Description: "查看账号状态"},
|
||||
{Command: "signin", Description: "签到"},
|
||||
{Command: "devices", Description: "查看登录设备"},
|
||||
{Command: "kick", Description: "踢下线设备"},
|
||||
{Command: "hideadult", Description: "隐藏/显示成人媒体库"},
|
||||
}
|
||||
}
|
||||
|
||||
func telegramAdminBotCommandMenu() []telegramBotCommand {
|
||||
commands := append([]telegramBotCommand{}, telegramPrivateBotCommandMenu()...)
|
||||
commands = append(commands,
|
||||
telegramBotCommand{Command: "status", Description: "系统运行状态(管理员)"},
|
||||
telegramBotCommand{Command: "search", Description: "搜索媒体库(管理员)"},
|
||||
telegramBotCommand{Command: "downloads", Description: "下载列表(管理员)"},
|
||||
telegramBotCommand{Command: "stats", Description: "媒体库统计(管理员)"},
|
||||
telegramBotCommand{Command: "users", Description: "用户管理(管理员)"},
|
||||
telegramBotCommand{Command: "cleanup", Description: "删号规则巡检(管理员)"},
|
||||
telegramBotCommand{Command: "cleanup_rule", Description: "保号规则管理(管理员)"},
|
||||
)
|
||||
return commands
|
||||
}
|
||||
|
||||
func registerTelegramBotCommands(ctx context.Context, cfg map[string]string) error {
|
||||
if strings.TrimSpace(cfg["bot_token"]) == "" {
|
||||
return nil
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"commands": telegramBotCommandMenu(),
|
||||
normalizeTelegramConfig(cfg)
|
||||
if err := telegramSetBotCommands(ctx, cfg, telegramPrivateBotCommandMenu(), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := telegramSetBotCommands(ctx, cfg, telegramPrivateBotCommandMenu(), map[string]interface{}{"type": "all_private_chats"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := telegramSetBotCommands(ctx, cfg, telegramGroupBotCommandMenu(), map[string]interface{}{"type": "all_group_chats"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
adminCommands := telegramAdminBotCommandMenu()
|
||||
for _, adminID := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) {
|
||||
_ = telegramSetBotCommands(ctx, cfg, adminCommands, map[string]interface{}{"type": "chat", "chat_id": adminID})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func telegramSetBotCommands(ctx context.Context, cfg map[string]string, commands []telegramBotCommand, scope map[string]interface{}) error {
|
||||
payload := map[string]interface{}{"commands": commands}
|
||||
if scope != nil {
|
||||
payload["scope"] = scope
|
||||
}
|
||||
return telegramPostJSON(ctx, cfg, "setMyCommands", payload, 15*time.Second)
|
||||
}
|
||||
|
||||
@@ -55,11 +55,36 @@ func (s *TelegramBotService) boundUser(ctx context.Context, telegramUserID int)
|
||||
// extra management section.
|
||||
func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) telegramCommandReply {
|
||||
isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID)
|
||||
isGroup := telegramIsGroupChat(msg.Chat.Type)
|
||||
user := s.boundUser(ctx, msg.From.ID)
|
||||
|
||||
var rows [][]telegramInlineButton
|
||||
var header string
|
||||
|
||||
if isGroup {
|
||||
if user == nil {
|
||||
header = "<b>MediaStationGo 群组自助菜单</b>\n\n你还没有绑定媒体中心账号。绑定、注册、兑换等包含敏感信息的操作请私聊 Bot。"
|
||||
} else {
|
||||
adult := map[bool]string{true: "已隐藏", false: "已显示"}[user.HideAdult]
|
||||
header = fmt.Sprintf("<b>MediaStationGo 群组自助菜单</b>\n\n账号:<b>%s</b>\n到期:<b>%s</b>\n成人目录:<b>%s</b>",
|
||||
user.Username, formatExpiry(user.ExpiredAt), adult)
|
||||
rows = append(rows,
|
||||
[]telegramInlineButton{
|
||||
{Text: "👤 我的账号", Data: "act_account"},
|
||||
{Text: "📅 签到", Data: "act_signin"},
|
||||
},
|
||||
[]telegramInlineButton{
|
||||
{Text: "📱 我的设备", Data: "act_devices"},
|
||||
{Text: map[bool]string{true: "🔞 显示成人目录", false: "🔞 隐藏成人目录"}[user.HideAdult], Data: "adult_toggle"},
|
||||
},
|
||||
)
|
||||
}
|
||||
if isAdmin {
|
||||
header += "\n\n" + telegramGroupPrivateAdminHint()
|
||||
}
|
||||
return telegramCommandReply{Text: header, Buttons: rows}
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
header = "<b>MediaStationGo</b>\n\n你还没有绑定媒体中心账号。"
|
||||
rows = append(rows, []telegramInlineButton{{Text: "🔗 绑定账号", Data: "act_bind"}})
|
||||
@@ -109,6 +134,7 @@ func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.Notify
|
||||
// handleMenuCallback routes inline-button taps. Returns (reply, handled).
|
||||
func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, data string) (telegramCommandReply, bool) {
|
||||
isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID)
|
||||
isGroup := telegramIsGroupChat(msg.Chat.Type)
|
||||
|
||||
switch {
|
||||
case data == "noop":
|
||||
@@ -116,17 +142,29 @@ func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *mo
|
||||
case data == "menu_main":
|
||||
return s.mainMenu(ctx, channel, msg), true
|
||||
case data == "act_bind":
|
||||
if isGroup {
|
||||
return telegramCommandReply{Text: telegramGroupPrivateUserHint("绑定账号")}, true
|
||||
}
|
||||
return telegramCommandReply{Text: "请发送:<code>/start 用户名 密码</code> 绑定已有账号。"}, true
|
||||
case data == "act_register":
|
||||
if isGroup {
|
||||
return telegramCommandReply{Text: telegramGroupPrivateUserHint("注册账号")}, true
|
||||
}
|
||||
if !s.openRegEnabled(ctx) {
|
||||
return telegramCommandReply{Text: "注册功能未开放,请联系管理员。"}, true
|
||||
}
|
||||
s.setPending(int64(msg.From.ID), "register")
|
||||
return telegramCommandReply{Text: "请发送新账号的 <b>用户名 密码</b>(空格分隔),例如:<code>alice mypass123</code>"}, true
|
||||
case data == "act_redeem_register":
|
||||
if isGroup {
|
||||
return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码注册")}, true
|
||||
}
|
||||
s.setPending(int64(msg.From.ID), "redeem_register")
|
||||
return telegramCommandReply{Text: "请发送你的<b>注册兑换码</b>,例如:<code>ABCD2345EFGH</code>\n(兑换后会要求设置用户名密码)"}, true
|
||||
case data == "act_redeem_renew":
|
||||
if isGroup {
|
||||
return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码续期")}, true
|
||||
}
|
||||
s.setPending(int64(msg.From.ID), "redeem_renew")
|
||||
return telegramCommandReply{Text: "请发送你的<b>续期兑换码</b>,将为当前绑定账号续期。"}, true
|
||||
case data == "act_account":
|
||||
@@ -136,9 +174,15 @@ func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *mo
|
||||
case data == "act_devices":
|
||||
return s.replyDevices(ctx, msg), true
|
||||
case data == "act_setname":
|
||||
if isGroup {
|
||||
return telegramCommandReply{Text: telegramGroupPrivateUserHint("修改用户名")}, true
|
||||
}
|
||||
s.setPending(int64(msg.From.ID), "setname")
|
||||
return telegramCommandReply{Text: "请发送:<code>当前密码 新用户名</code>。"}, true
|
||||
case data == "act_setpass":
|
||||
if isGroup {
|
||||
return telegramCommandReply{Text: telegramGroupPrivateUserHint("修改密码")}, true
|
||||
}
|
||||
s.setPending(int64(msg.From.ID), "setpass")
|
||||
return telegramCommandReply{Text: "请发送:<code>当前密码 新密码</code>(新密码至少 6 位)。"}, true
|
||||
case strings.HasPrefix(data, "kick:"):
|
||||
@@ -146,6 +190,12 @@ func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *mo
|
||||
}
|
||||
|
||||
// ── 管理员专属 ──
|
||||
if isGroup {
|
||||
if isAdmin {
|
||||
return telegramCommandReply{Text: telegramGroupPrivateAdminHint()}, true
|
||||
}
|
||||
return telegramCommandReply{}, true
|
||||
}
|
||||
if !isAdmin {
|
||||
return telegramCommandReply{Text: "此功能仅管理员可用。"}, true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user