From 4a3ed773957063aaadea79810f3dd8adbd59232d Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:12:55 +0800 Subject: [PATCH] Fix organize metadata matching and Telegram group privacy --- internal/service/organizer.go | 13 +- internal/service/organizer_directory.go | 101 ++++++++++++++- internal/service/organizer_scrape_test.go | 33 +++++ internal/service/service.go | 1 + internal/service/telegram_api_test.go | 146 ++++++++++++++++++++++ internal/service/telegram_bot.go | 66 +++++++++- internal/service/telegram_commands.go | 6 +- 7 files changed, 353 insertions(+), 13 deletions(-) diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 8f79f71..73f6df4 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -31,10 +31,11 @@ import ( // OrganizerService moves/renames files into library structures. type OrganizerService struct { - cfg *config.Config - log *zap.Logger - repo *repository.Container - probe *FFprobeService // optional; used for 洗版 resolution comparison + cfg *config.Config + log *zap.Logger + repo *repository.Container + probe *FFprobeService // optional; used for 洗版 resolution comparison + scraper *ScraperService // optional; used to identify metadata before rename } // NewOrganizerService is the constructor. @@ -47,6 +48,10 @@ func NewOrganizerService(cfg *config.Config, log *zap.Logger, repo *repository.C // Optional: when nil the organizer falls back to filename resolution tokens. func (o *OrganizerService) SetProbe(p *FFprobeService) { o.probe = p } +// SetScraper wires the scraper so directory organize can resolve TMDb/Bangumi +// metadata before it decides the final folder and filename. +func (o *OrganizerService) SetScraper(s *ScraperService) { o.scraper = s } + // OrganizeResult reports what happened. type OrganizeResult struct { Organized int `json:"organized"` diff --git a/internal/service/organizer_directory.go b/internal/service/organizer_directory.go index cae7703..0f4e0c7 100644 --- a/internal/service/organizer_directory.go +++ b/internal/service/organizer_directory.go @@ -138,6 +138,7 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO } mode := o.resolveTransferMode(ctx, opts.TransferMode) res := &OrganizeResult{SourcePath: source, DestPath: dest, DryRun: opts.DryRun} + metadataCache := map[string]*Match{} if !info.IsDir() { ext := strings.ToLower(filepath.Ext(source)) if _, ok := videoExtensions[ext]; !ok { @@ -157,7 +158,7 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO ) return res, nil } - if err := o.organizeSourceFile(ctx, source, filepath.Dir(source), dest, mode, opts.MediaType, opts.MediaCategory, opts.DryRun, opts.AllowReplaceExisting, res); err != nil { + if err := o.organizeSourceFile(ctx, source, filepath.Dir(source), dest, mode, opts.MediaType, opts.MediaCategory, opts.DryRun, opts.AllowReplaceExisting, metadataCache, res); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", filepath.Base(source), err.Error())) res.Items = append(res.Items, OrganizePreviewItem{Source: source, Action: "error", Reason: err.Error()}) } @@ -185,7 +186,7 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO res.Items = append(res.Items, OrganizePreviewItem{Source: path, Action: "skip", Reason: reason}) return nil } - if err := o.organizeSourceFile(ctx, path, source, dest, mode, opts.MediaType, opts.MediaCategory, opts.DryRun, opts.AllowReplaceExisting, res); err != nil { + if err := o.organizeSourceFile(ctx, path, source, dest, mode, opts.MediaType, opts.MediaCategory, opts.DryRun, opts.AllowReplaceExisting, metadataCache, res); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", filepath.Base(path), err.Error())) res.Items = append(res.Items, OrganizePreviewItem{Source: path, Action: "error", Reason: err.Error()}) } @@ -239,7 +240,7 @@ type organizeDirectoryLayout struct { // organizeSourceFile organizes a single video file from the source directory // into destRoot, applying dedup + 洗版. -func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRoot, destRoot string, mode TransferMode, mediaTypeOverride, mediaCategoryOverride string, dryRun bool, allowReplaceExisting bool, res *OrganizeResult) error { +func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRoot, destRoot string, mode TransferMode, mediaTypeOverride, mediaCategoryOverride string, dryRun bool, allowReplaceExisting bool, metadataCache map[string]*Match, res *OrganizeResult) error { ext := filepath.Ext(src) title, year := CleanQuery(src) if title == "" { @@ -263,6 +264,15 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo if layout.MediaType == "" { layout.MediaType = o.inferMediaTypeForSourceFile(src, title, season, episode) } + if match := o.lookupOrganizeMetadata(ctx, src, sourceRoot, layout.MediaType, title, year, season, episode, metadataCache); match != nil { + if matchedTitle := sanitizeFilename(strings.TrimSpace(match.Title)); matchedTitle != "" { + title = matchedTitle + parsedTitle = strings.TrimSpace(match.Title) + } + if match.Year > 0 { + year = match.Year + } + } if category := strings.TrimSpace(mediaCategoryOverride); category != "" { layout.Category = sanitizeFilename(category) } else if layout.Category == "" { @@ -445,6 +455,91 @@ func (o *OrganizerService) inferMediaTypeForSourceFile(src, title string, season return normalizeMediaType("", title, src) } +func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sourceRoot, mediaType, title string, year, season, episode int, cache map[string]*Match) *Match { + seriesLike := isSeriesLibraryType(mediaType) || season > 0 || episode > 0 + if local, err := ReadLocalMetadata(src, sourceRoot, seriesLike); err == nil && local != nil { + if match := organizeMatchFromLocalMetadata(local); match != nil { + return match + } + } else if err != nil && o.log != nil { + o.log.Debug("organize read local metadata before rename failed", zap.String("path", src), zap.Error(err)) + } + if o == nil || o.scraper == nil || !o.scraper.AnyEnabled() { + return nil + } + libType := normalizeOrganizeMediaType(mediaType) + if libType == "" { + libType = organizeLibraryModelType(mediaType) + } + lib := &model.Library{Path: sourceRoot, Type: libType, Enabled: true} + media := &model.Media{ + Title: title, + Year: year, + Path: src, + SeasonNum: season, + EpisodeNum: episode, + } + for _, candidate := range scrapeQueryCandidates(media, lib) { + key := organizeMetadataCacheKey(lib.Type, candidate, year) + if cache != nil { + if cached, ok := cache[key]; ok { + if cached != nil { + return cached + } + continue + } + } + match := o.scraper.lookup(ctx, lib, candidate, year) + if cache != nil { + cache[key] = match + } + if match != nil && strings.TrimSpace(match.Title) != "" { + if o.log != nil { + o.log.Info("organize metadata matched before rename", + zap.String("source", src), + zap.String("query", candidate), + zap.String("title", match.Title), + zap.Int("year", match.Year), + zap.Int("tmdb_id", match.TMDbID), + zap.Int("bangumi_id", match.BangumiID)) + } + return match + } + } + return nil +} + +func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match { + if local == nil || strings.TrimSpace(local.Title) == "" { + return nil + } + match := &Match{ + Title: strings.TrimSpace(local.Title), + OriginalName: strings.TrimSpace(local.OriginalName), + Overview: local.Overview, + PosterURL: local.PosterURL, + BackdropURL: local.BackdropURL, + Year: local.Year, + Rating: local.Rating, + TMDbID: local.TMDbID, + NSFW: local.NSFW, + } + if local.Genres != "" { + match.Genres = splitNFOList(local.Genres) + } + if local.Countries != "" { + match.Countries = splitNFOList(local.Countries) + } + if local.Languages != "" { + match.Languages = splitNFOList(local.Languages) + } + return match +} + +func organizeMetadataCacheKey(mediaType, query string, year int) string { + return strings.ToLower(strings.TrimSpace(mediaType)) + "|" + fmt.Sprint(year) + "|" + strings.ToLower(strings.TrimSpace(query)) +} + func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sourceRoot, mediaType, title, parsedTitle string) string { if o == nil || !o.isSmartClassifyEnabled(ctx) { return "" diff --git a/internal/service/organizer_scrape_test.go b/internal/service/organizer_scrape_test.go index 7d066c8..9689e86 100644 --- a/internal/service/organizer_scrape_test.go +++ b/internal/service/organizer_scrape_test.go @@ -69,6 +69,39 @@ func TestOrganizeDirectoryScanAndScrapeAfter(t *testing.T) { } } +func TestOrganizeDirectoryUsesScraperMatchBeforeRename(t *testing.T) { + scraper, repos, closeServer := newTestScraper(t) + defer closeServer() + + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + sourceFile := filepath.Join(src, "Spy.x.Family.S01E01.2022.1080p.mkv") + writeOrgFile(t, sourceFile, "episode") + + organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + organizer.SetScraper(scraper) + res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + TransferMode: TransferCopy, + MediaType: "tv", + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + if res.Organized != 1 { + t.Fatalf("organized = %d, want 1", res.Organized) + } + want := filepath.Join(dest, "电视剧", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Fatalf("organized file should use matched metadata path %q: %v; items=%#v", want, err, res.Items) + } + if len(res.Items) != 1 || res.Items[0].Target != want || res.Items[0].Title != "间谍过家家" { + t.Fatalf("organize preview did not use scraper metadata: %#v", res.Items) + } +} + func TestOrganizeScanAndScrapeRetriesNoMatchRows(t *testing.T) { scraper, repos, closeServer := newTestScraper(t) defer closeServer() diff --git a/internal/service/service.go b/internal/service/service.go index 5d78259..c4cdfb3 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -101,6 +101,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, thetvdb, fanart, hub, adult) organizer := NewOrganizerService(cfg, log, repos) organizer.SetProbe(probe) + organizer.SetScraper(scraper) discover := NewDiscoverService(log, tmdb) transcoder := NewTranscoderService(cfg, log, repos, hub) scanner := NewScannerService(cfg, log, repos, hub, probe, scraper) diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go index 026e3e8..7d57051 100644 --- a/internal/service/telegram_api_test.go +++ b/internal/service/telegram_api_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -11,6 +12,7 @@ import ( "time" "github.com/ShukeBta/MediaStationGo/internal/model" + "go.uber.org/zap" ) func TestTelegramMethodURLUsesCustomAPIBase(t *testing.T) { @@ -249,6 +251,150 @@ func TestTelegramReplyAutoDeletesSentMessage(t *testing.T) { waitForTelegramMethod(t, requests, "deleteMessage") } +func TestTelegramGroupCommandSendsPanelPrivately(t *testing.T) { + var payloads []struct { + ChatID any `json:"chat_id"` + Text string `json:"text"` + ReplyMarkup map[string]any `json:"reply_markup"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/sendMessage") { + http.NotFound(w, r) + return + } + var payload struct { + ChatID any `json:"chat_id"` + Text string `json:"text"` + ReplyMarkup map[string]any `json:"reply_markup"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode sendMessage: %v", err) + } + payloads = append(payloads, payload) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":777}}`)) + })) + defer server.Close() + + repos, bot := newBotTestService(t) + cfg, _ := json.Marshal(map[string]string{ + "bot_token": "123456:ABC-def", + "api_base_url": server.URL, + "group_chat_id": "-100123", + "auto_delete_seconds": "-1", + }) + if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}).Error; err != nil { + t.Fatalf("create channel: %v", err) + } + update, _ := json.Marshal(TelegramUpdate{ + UpdateID: 1, + Message: &TelegramMessage{ + MessageID: 55, + From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"}, + Chat: TelegramChat{ID: -100123, Type: "supergroup"}, + Text: "/menu", + }, + }) + if err := bot.HandleWebhook(t.Context(), update); err != nil { + t.Fatalf("handle webhook: %v", err) + } + if len(payloads) != 2 { + t.Fatalf("sendMessage count = %d, payloads=%#v", len(payloads), payloads) + } + if got := fmt.Sprint(payloads[0].ChatID); got != "9002" { + t.Fatalf("first message should be private to requester, chat_id=%s payload=%#v", got, payloads[0]) + } + if payloads[0].ReplyMarkup == nil { + t.Fatalf("private panel should include inline keyboard: %#v", payloads[0]) + } + if got := fmt.Sprint(payloads[1].ChatID); got != "-100123" { + t.Fatalf("second message should be group ack, chat_id=%s payload=%#v", got, payloads[1]) + } + if payloads[1].ReplyMarkup != nil { + t.Fatalf("group ack must not expose buttons: %#v", payloads[1]) + } + if !strings.Contains(payloads[1].Text, "私聊") { + t.Fatalf("group ack should explain private delivery, got %q", payloads[1].Text) + } +} + +func TestTelegramGroupCallbackIsRejected(t *testing.T) { + var callbackPayloads []struct { + CallbackID string `json:"callback_query_id"` + Text string `json:"text"` + ShowAlert bool `json:"show_alert"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/answerCallbackQuery") { + http.NotFound(w, r) + return + } + var payload struct { + CallbackID string `json:"callback_query_id"` + Text string `json:"text"` + ShowAlert bool `json:"show_alert"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode answerCallbackQuery: %v", err) + } + callbackPayloads = append(callbackPayloads, payload) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"result":true}`)) + })) + defer server.Close() + + ctx := t.Context() + repos, auth, _, _ := newAuthTestServices(t) + user, _, err := auth.Register(ctx, "viewer", "secret-pass") + if err != nil { + t.Fatalf("register user: %v", err) + } + if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil { + t.Fatalf("migrate notify channel: %v", err) + } + if err := repos.DB.Create(&model.TelegramBinding{ + TelegramUserID: 9002, + TelegramName: "@viewer", + ChatID: 9002, + UserID: user.ID, + }).Error; err != nil { + t.Fatalf("create binding: %v", err) + } + cfg, _ := json.Marshal(map[string]string{ + "bot_token": "123456:ABC-def", + "api_base_url": server.URL, + "group_chat_id": "-100123", + "auto_delete_seconds": "-1", + }) + if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}).Error; err != nil { + t.Fatalf("create channel: %v", err) + } + before, _ := repos.User.FindByID(ctx, user.ID) + bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) + update, _ := json.Marshal(TelegramUpdate{ + UpdateID: 2, + CallbackQuery: &TelegramCallbackQuery{ + ID: "cb-group", + From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"}, + Message: &TelegramMessage{MessageID: 56, Chat: TelegramChat{ID: -100123, Type: "supergroup"}}, + Data: "adult_toggle", + }, + }) + if err := bot.HandleWebhook(ctx, update); err != nil { + t.Fatalf("handle webhook: %v", err) + } + if len(callbackPayloads) != 1 { + t.Fatalf("answerCallbackQuery count = %d", len(callbackPayloads)) + } + if !callbackPayloads[0].ShowAlert || !strings.Contains(callbackPayloads[0].Text, "群组内按钮面板已禁用") { + t.Fatalf("unexpected callback answer: %#v", callbackPayloads[0]) + } + after, _ := repos.User.FindByID(ctx, user.ID) + if before == nil || after == nil || before.HideAdult != after.HideAdult { + t.Fatalf("group callback should not mutate user adult visibility: before=%#v after=%#v", before, after) + } +} + func waitForTelegramMethod(t *testing.T, requests <-chan string, want string) { t.Helper() deadline := time.After(2 * time.Second) diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index 54d414a..d3253f4 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -217,13 +217,13 @@ func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) err reply, err := s.executeCommand(ctx, channel, msg, text) if err != nil { s.log.Error("command failed", zap.Error(err)) - _ = s.reply(ctx, channel, msg.Chat.ID, telegramCommandReply{Text: "命令执行失败: " + err.Error()}) + _ = s.replyForMessage(ctx, channel, msg, telegramCommandReply{Text: "命令执行失败: " + err.Error()}) s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID) return nil } if reply.Text != "" { - if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil { + if err := s.replyForMessage(ctx, channel, msg, reply); err != nil { s.log.Error("reply failed", zap.Error(err)) } s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID) @@ -255,6 +255,15 @@ func telegramIsGroupChat(chatType string) bool { return chatType != "" && chatType != "private" } +func telegramPrivateMessageForUser(msg *TelegramMessage) *TelegramMessage { + if msg == nil || !telegramIsGroupChat(msg.Chat.Type) { + return msg + } + copied := *msg + copied.Chat = TelegramChat{ID: msg.From.ID, Type: "private"} + return &copied +} + func telegramGroupPrivateAdminHint() string { return "管理命令请私聊 Bot 使用 /menu 或对应管理员命令,避免在群组公开管理面板。" } @@ -267,6 +276,14 @@ func telegramGroupPrivateUserHint(action string) string { return action + "包含账号凭据或敏感信息,请私聊 Bot 操作;群组内仅开放账号状态、签到、设备与成人目录开关。" } +func telegramGroupPrivateDeliverySentHint() string { + return "已把你的 Bot 面板/执行结果私聊发送给你。若没收到,请先私聊 Bot 发送 /start。" +} + +func telegramGroupPrivateDeliveryFailedHint() string { + return "无法私聊发送给你。请先打开 Bot 私聊窗口发送 /start,再回群里使用命令。" +} + // cmdStart 处理 /start 命令。 func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { name := msg.From.FirstName @@ -930,6 +947,29 @@ func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyCha return nil } +func (s *TelegramBotService) replyForMessage(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, reply telegramCommandReply) error { + if msg == nil { + return nil + } + if strings.TrimSpace(reply.Text) == "" { + return nil + } + if !telegramIsGroupChat(msg.Chat.Type) { + return s.reply(ctx, channel, msg.Chat.ID, reply) + } + if err := s.reply(ctx, channel, msg.From.ID, reply); err != nil { + if s.log != nil { + s.log.Warn("telegram private reply from group failed", + zap.Int("group_chat_id", msg.Chat.ID), + zap.Int("telegram_user_id", msg.From.ID), + zap.Error(sanitizeTelegramError(err)), + ) + } + return s.reply(ctx, channel, msg.Chat.ID, telegramCommandReply{Text: telegramGroupPrivateDeliveryFailedHint()}) + } + return s.reply(ctx, channel, msg.Chat.ID, telegramCommandReply{Text: telegramGroupPrivateDeliverySentHint()}) +} + func (s *TelegramBotService) deleteTelegramSourceMessage(channel *model.NotifyChannel, chatID, messageID int) { if messageID <= 0 { return @@ -1057,6 +1097,15 @@ func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCal channel = s.findChannelByChatID(ctx, cb.Message.Chat.ID) } // 立即应答回调,关闭按钮上的加载状态,避免客户端长时间转圈。 + if telegramIsGroupChat(cb.Message.Chat.Type) { + s.answerCallbackWithText(ctx, channel, cb.ID, "为了隐私,群组内按钮面板已禁用。请私聊 Bot 或在群里发送 /menu,我会把面板私聊给你。", true) + s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID) + return nil + } + if cb.Message.Chat.Type == "private" && cb.Message.Chat.ID != cb.From.ID { + s.answerCallbackWithText(ctx, channel, cb.ID, "这个面板不属于你,请发送 /menu 打开自己的面板。", true) + return nil + } s.answerCallback(ctx, channel, cb.ID) data := strings.TrimSpace(cb.Data) if data == "adult_toggle" { @@ -1080,6 +1129,10 @@ func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCal // answerCallback 应答 Telegram 回调查询,关闭按钮上的加载提示。 func (s *TelegramBotService) answerCallback(ctx context.Context, channel *model.NotifyChannel, callbackID string) { + s.answerCallbackWithText(ctx, channel, callbackID, "", false) +} + +func (s *TelegramBotService) answerCallbackWithText(ctx context.Context, channel *model.NotifyChannel, callbackID, text string, showAlert bool) { if channel == nil || strings.TrimSpace(callbackID) == "" { return } @@ -1087,9 +1140,14 @@ func (s *TelegramBotService) answerCallback(ctx context.Context, channel *model. if strings.TrimSpace(cfg["bot_token"]) == "" { return } - if err := telegramPostJSON(ctx, cfg, "answerCallbackQuery", map[string]interface{}{ + payload := map[string]interface{}{ "callback_query_id": callbackID, - }, 8*time.Second); err != nil { + } + if strings.TrimSpace(text) != "" { + payload["text"] = text + payload["show_alert"] = showAlert + } + if err := telegramPostJSON(ctx, cfg, "answerCallbackQuery", payload, 8*time.Second); err != nil { s.log.Debug("telegram answerCallbackQuery failed", zap.Error(sanitizeTelegramError(err))) } } diff --git a/internal/service/telegram_commands.go b/internal/service/telegram_commands.go index 5d51b57..0bd0c19 100644 --- a/internal/service/telegram_commands.go +++ b/internal/service/telegram_commands.go @@ -25,11 +25,13 @@ func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, cha return []telegramCommandDefinition{ {Aliases: []string{"/start"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { if len(args) == 0 { - return s.mainMenu(ctx, channel, msg), nil + return s.mainMenu(ctx, channel, telegramPrivateMessageForUser(msg)), nil } return s.cmdStart(ctx, msg, args), nil }}, - {Aliases: []string{"/menu"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.mainMenu(ctx, channel, msg), nil }}, + {Aliases: []string{"/menu"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { + return s.mainMenu(ctx, channel, telegramPrivateMessageForUser(msg)), nil + }}, {Aliases: []string{"/cancel"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { s.takePending(int64(msg.From.ID)) return telegramCommandReply{Text: "已取消当前操作。"}, nil