mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Fix organize metadata matching and Telegram group privacy
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 使用 <code>/menu</code> 或对应管理员命令,避免在群组公开管理面板。"
|
||||
}
|
||||
@@ -267,6 +276,14 @@ func telegramGroupPrivateUserHint(action string) string {
|
||||
return action + "包含账号凭据或敏感信息,请私聊 Bot 操作;群组内仅开放账号状态、签到、设备与成人目录开关。"
|
||||
}
|
||||
|
||||
func telegramGroupPrivateDeliverySentHint() string {
|
||||
return "已把你的 Bot 面板/执行结果私聊发送给你。若没收到,请先私聊 Bot 发送 <code>/start</code>。"
|
||||
}
|
||||
|
||||
func telegramGroupPrivateDeliveryFailedHint() string {
|
||||
return "无法私聊发送给你。请先打开 Bot 私聊窗口发送 <code>/start</code>,再回群里使用命令。"
|
||||
}
|
||||
|
||||
// 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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user