diff --git a/internal/service/telegram_binding.go b/internal/service/telegram_binding.go index 1130b6a..cb16c66 100644 --- a/internal/service/telegram_binding.go +++ b/internal/service/telegram_binding.go @@ -2,155 +2,14 @@ package service import ( "context" - "encoding/json" "errors" - "fmt" - "strconv" "strings" - "time" - "go.uber.org/zap" "gorm.io/gorm" "github.com/ShukeBta/MediaStationGo/internal/model" ) -// findChannelByChatID 根据 chat_id 查找已配置的通知渠道。 -func (s *TelegramBotService) findChannelByChatID(ctx context.Context, chatID int) *model.NotifyChannel { - channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram") - if err != nil { - return nil - } - target := strconv.Itoa(chatID) - for _, ch := range channels { - if !ch.Enabled { - continue - } - configStr := ch.Config - if s.crypto != nil && configStr != "" { - configStr = s.crypto.Decrypt(configStr) - } - var cfg map[string]string - if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { - continue - } - if cfg["chat_id"] == target || cfg["command_chat_id"] == target || - cfg["group_chat_id"] == target || cfg["channel_chat_id"] == target { - return &ch - } - } - if len(channels) == 1 && channels[0].Enabled { - return &channels[0] - } - return nil -} - -func (s *TelegramBotService) findChannelForMessage(ctx context.Context, msg *TelegramMessage) *model.NotifyChannel { - if msg == nil { - return nil - } - if msg.Chat.Type != "" && msg.Chat.Type != "private" { - return s.findChannelByChatID(ctx, msg.Chat.ID) - } - channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram") - if err != nil { - return nil - } - var first *model.NotifyChannel - for i := range channels { - ch := channels[i] - if !ch.Enabled { - continue - } - if first == nil { - first = &ch - } - if s.telegramUserIsAdmin(ctx, &ch, msg.From.ID) || s.telegramUserCanBind(ctx, &ch, msg.From.ID) { - return &ch - } - } - return first -} - -func (s *TelegramBotService) channelForMessage(ctx context.Context, msg *TelegramMessage, hint *model.NotifyChannel) *model.NotifyChannel { - if hint == nil { - return s.findChannelForMessage(ctx, msg) - } - if msg == nil { - return hint - } - if msg.Chat.Type != "" && msg.Chat.Type != "private" && !s.telegramChatAllowed(hint, msg.Chat.ID) { - return nil - } - return hint -} - -func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCallbackQuery, channelHint *model.NotifyChannel) error { - if cb == nil || cb.Message == nil { - return nil - } - msg := *cb.Message - msg.From = cb.From - channel := s.channelForMessage(ctx, &msg, channelHint) - if channel == nil { - 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" { - reply := s.cmdHideAdult(ctx, &msg, nil) - if reply.Text != "" { - err := s.reply(ctx, channel, cb.Message.Chat.ID, reply) - s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID) - return err - } - return nil - } - if reply, handled := s.handleMenuCallback(ctx, channel, &msg, data); handled { - if reply.Text != "" { - err := s.reply(ctx, channel, cb.Message.Chat.ID, reply) - s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID) - return err - } - } - return nil -} - -// 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 - } - cfg := s.telegramChannelConfig(channel) - if strings.TrimSpace(cfg["bot_token"]) == "" { - return - } - payload := map[string]interface{}{ - "callback_query_id": callbackID, - } - 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))) - } -} - func (s *TelegramBotService) telegramBinding(ctx context.Context, telegramUserID int) *model.TelegramBinding { if telegramUserID == 0 { return nil @@ -172,196 +31,6 @@ func (s *TelegramBotService) unbindTelegramUser(ctx context.Context, telegramUse Delete(&model.TelegramBinding{}).Error } -func (s *TelegramBotService) telegramUserIsAdmin(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool { - if s.telegramUserIDConfigured(channel, telegramUserID) { - return true - } - binding := s.telegramBinding(ctx, telegramUserID) - if binding == nil { - return false - } - user, err := s.repo.User.FindByID(ctx, binding.UserID) - return err == nil && user != nil && user.Role == "admin" && user.IsActive -} - -func (s *TelegramBotService) telegramChatAllowed(channel *model.NotifyChannel, chatID int) bool { - if channel == nil { - return false - } - configStr := channel.Config - if s.crypto != nil && configStr != "" { - configStr = s.crypto.Decrypt(configStr) - } - var cfg map[string]string - if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { - return false - } - target := strconv.Itoa(chatID) - for _, key := range []string{"group_chat_id", "channel_chat_id", "command_chat_id"} { - if configured := strings.TrimSpace(cfg[key]); configured != "" && configured == target { - return true - } - } - if strings.TrimSpace(cfg["group_chat_id"]) != "" || strings.TrimSpace(cfg["channel_chat_id"]) != "" || strings.TrimSpace(cfg["command_chat_id"]) != "" { - return false - } - return strings.TrimSpace(cfg["chat_id"]) == target -} - -// telegramBindDecision 表示成员资格校验的三态结果:通过 / 明确不通过 / -// 无法验证(getChatMember 出错,如 Bot 不在群、群 ID 失效、网络或代理不可达)。 -// 区分「明确不是成员」和「查不了」,是为了避免把验证失败误报成「你不在群」。 -type telegramBindDecision int - -const ( - bindDenied telegramBindDecision = iota // 已查实:不在任何绑定群组/频道 - bindAllowed // 管理员,或查实是某绑定群组/频道成员 - bindUnverifiable // 配了群组/频道但 getChatMember 全部失败 -) - -// telegramMembership 表示单个 chat 的成员资格三态。 -type telegramMembership int - -const ( - membershipNo telegramMembership = iota // 查实不是成员(left/kicked 等) - membershipYes // 查实是成员 - membershipUnknown // getChatMember 出错,无法判定 -) - -func (s *TelegramBotService) telegramUserBindDecision(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) telegramBindDecision { - if telegramUserID == 0 || channel == nil { - return bindDenied - } - if s.telegramUserIDConfigured(channel, telegramUserID) { - return bindAllowed - } - chatIDs := s.telegramMembershipChatIDs(channel) - if len(chatIDs) == 0 { - return bindDenied - } - sawUnknown := false - for _, chatID := range chatIDs { - switch s.telegramChatMembership(ctx, channel, chatID, telegramUserID) { - case membershipYes: - return bindAllowed - case membershipUnknown: - sawUnknown = true - } - } - if sawUnknown { - return bindUnverifiable - } - return bindDenied -} - -// telegramUserCanBind 是 telegramUserBindDecision 的布尔包装,供尽力而为的场景 -// 使用(如私聊时挑选可用渠道):只有查实通过才返回 true。 -func (s *TelegramBotService) telegramUserCanBind(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool { - return s.telegramUserBindDecision(ctx, channel, telegramUserID) == bindAllowed -} - -// telegramBindRejectText 根据三态结果生成面向用户的提示。action 形如「兑换注册账号」 -// 「绑定媒体中心账号」。bindUnverifiable 时不再误导用户「你不在群」,而是提示 -// 管理员检查 Bot 权限与群组 ID。 -func telegramBindRejectText(decision telegramBindDecision, action string) string { - if decision == bindUnverifiable { - return fmt.Sprintf("暂时无法验证你的群组/频道成员身份,%s未成功。这通常是因为 Bot 未加入绑定群组、在频道中不是管理员,或群组 ID 配置有误(如超级群需带 -100 前缀)。请联系管理员检查 Bot 权限与「绑定群组/频道 ID」。", action) - } - return fmt.Sprintf("当前 Telegram 账号不在管理员配置的绑定群组/频道中,无法%s。请先加入管理员配置的群组或频道;如果尚未配置,请联系管理员。", action) -} - -func (s *TelegramBotService) telegramChatMembership(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) telegramMembership { - cfg := s.telegramChannelConfig(channel) - if strings.TrimSpace(cfg["bot_token"]) == "" || chatID == "" || telegramUserID == 0 { - return membershipUnknown - } - payload := map[string]interface{}{ - "chat_id": chatID, - "user_id": telegramUserID, - } - var result struct { - OK bool `json:"ok"` - Result struct { - Status string `json:"status"` - } `json:"result"` - } - if err := telegramPostJSONDecode(ctx, cfg, "getChatMember", payload, 15*time.Second, &result); err != nil { - s.log.Warn("telegram getChatMember failed", zap.String("chat_id", chatID), zap.Int("telegram_user_id", telegramUserID), zap.Error(sanitizeTelegramError(err))) - return membershipUnknown - } - if !result.OK { - return membershipUnknown - } - switch strings.ToLower(result.Result.Status) { - case "creator", "administrator", "member", "restricted": - return membershipYes - default: - return membershipNo - } -} - -// telegramUserIsChatMember 是 telegramChatMembership 的布尔包装,仅在查实是成员时 -// 返回 true(查不了也视为非成员,供尽力而为的场景使用)。 -func (s *TelegramBotService) telegramUserIsChatMember(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) bool { - return s.telegramChatMembership(ctx, channel, chatID, telegramUserID) == membershipYes -} - -func (s *TelegramBotService) telegramUserIDConfigured(channel *model.NotifyChannel, telegramUserID int) bool { - if channel == nil || telegramUserID == 0 { - return false - } - cfg := s.telegramChannelConfig(channel) - target := strconv.Itoa(telegramUserID) - for _, value := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) { - if value == target { - return true - } - } - if strings.TrimSpace(cfg["admin_user_ids"]) == "" && strings.TrimSpace(cfg["chat_id"]) == target { - return true - } - return false -} - -func (s *TelegramBotService) telegramChannelConfig(channel *model.NotifyChannel) map[string]string { - return telegramConfigFromChannel(s.crypto, channel) -} - -func telegramConfigFromChannel(crypto *CryptoService, channel *model.NotifyChannel) map[string]string { - if channel == nil { - return map[string]string{} - } - configStr := channel.Config - if crypto != nil && configStr != "" { - configStr = crypto.Decrypt(configStr) - } - var cfg map[string]string - if err := json.Unmarshal([]byte(configStr), &cfg); err != nil || cfg == nil { - return map[string]string{} - } - normalizeTelegramConfig(cfg) - return cfg -} - -func normalizeTelegramConfig(cfg map[string]string) { - if cfg == nil { - return - } - chatID := strings.TrimSpace(cfg["chat_id"]) - if chatID == "" { - return - } - if strings.HasPrefix(chatID, "-") { - if strings.TrimSpace(cfg["group_chat_id"]) == "" && strings.TrimSpace(cfg["channel_chat_id"]) == "" && strings.TrimSpace(cfg["command_chat_id"]) == "" { - cfg["group_chat_id"] = chatID - } - return - } - if strings.TrimSpace(cfg["admin_user_ids"]) == "" { - cfg["admin_user_ids"] = chatID - } -} - func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *TelegramMessage, userID string) error { name := strings.TrimSpace(msg.From.FirstName) if msg.From.Username != "" { diff --git a/internal/service/telegram_callback.go b/internal/service/telegram_callback.go new file mode 100644 index 0000000..08816ae --- /dev/null +++ b/internal/service/telegram_callback.go @@ -0,0 +1,77 @@ +package service + +import ( + "context" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCallbackQuery, channelHint *model.NotifyChannel) error { + if cb == nil || cb.Message == nil { + return nil + } + msg := *cb.Message + msg.From = cb.From + channel := s.channelForMessage(ctx, &msg, channelHint) + if channel == nil { + 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" { + reply := s.cmdHideAdult(ctx, &msg, nil) + if reply.Text != "" { + err := s.reply(ctx, channel, cb.Message.Chat.ID, reply) + s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID) + return err + } + return nil + } + if reply, handled := s.handleMenuCallback(ctx, channel, &msg, data); handled { + if reply.Text != "" { + err := s.reply(ctx, channel, cb.Message.Chat.ID, reply) + s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID) + return err + } + } + return nil +} + +// 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 + } + cfg := s.telegramChannelConfig(channel) + if strings.TrimSpace(cfg["bot_token"]) == "" { + return + } + payload := map[string]interface{}{ + "callback_query_id": callbackID, + } + 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_channel_resolve.go b/internal/service/telegram_channel_resolve.go new file mode 100644 index 0000000..af30068 --- /dev/null +++ b/internal/service/telegram_channel_resolve.go @@ -0,0 +1,160 @@ +package service + +import ( + "context" + "encoding/json" + "strconv" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +// findChannelByChatID 根据 chat_id 查找已配置的通知渠道。 +func (s *TelegramBotService) findChannelByChatID(ctx context.Context, chatID int) *model.NotifyChannel { + channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram") + if err != nil { + return nil + } + target := strconv.Itoa(chatID) + for _, ch := range channels { + if !ch.Enabled { + continue + } + configStr := ch.Config + if s.crypto != nil && configStr != "" { + configStr = s.crypto.Decrypt(configStr) + } + var cfg map[string]string + if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { + continue + } + if cfg["chat_id"] == target || cfg["command_chat_id"] == target || + cfg["group_chat_id"] == target || cfg["channel_chat_id"] == target { + return &ch + } + } + if len(channels) == 1 && channels[0].Enabled { + return &channels[0] + } + return nil +} + +func (s *TelegramBotService) findChannelForMessage(ctx context.Context, msg *TelegramMessage) *model.NotifyChannel { + if msg == nil { + return nil + } + if msg.Chat.Type != "" && msg.Chat.Type != "private" { + return s.findChannelByChatID(ctx, msg.Chat.ID) + } + channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram") + if err != nil { + return nil + } + var first *model.NotifyChannel + for i := range channels { + ch := channels[i] + if !ch.Enabled { + continue + } + if first == nil { + first = &ch + } + if s.telegramUserIsAdmin(ctx, &ch, msg.From.ID) || s.telegramUserCanBind(ctx, &ch, msg.From.ID) { + return &ch + } + } + return first +} + +func (s *TelegramBotService) channelForMessage(ctx context.Context, msg *TelegramMessage, hint *model.NotifyChannel) *model.NotifyChannel { + if hint == nil { + return s.findChannelForMessage(ctx, msg) + } + if msg == nil { + return hint + } + if msg.Chat.Type != "" && msg.Chat.Type != "private" && !s.telegramChatAllowed(hint, msg.Chat.ID) { + return nil + } + return hint +} + +func (s *TelegramBotService) telegramChatAllowed(channel *model.NotifyChannel, chatID int) bool { + if channel == nil { + return false + } + configStr := channel.Config + if s.crypto != nil && configStr != "" { + configStr = s.crypto.Decrypt(configStr) + } + var cfg map[string]string + if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { + return false + } + target := strconv.Itoa(chatID) + for _, key := range []string{"group_chat_id", "channel_chat_id", "command_chat_id"} { + if configured := strings.TrimSpace(cfg[key]); configured != "" && configured == target { + return true + } + } + if strings.TrimSpace(cfg["group_chat_id"]) != "" || strings.TrimSpace(cfg["channel_chat_id"]) != "" || strings.TrimSpace(cfg["command_chat_id"]) != "" { + return false + } + return strings.TrimSpace(cfg["chat_id"]) == target +} + +func (s *TelegramBotService) telegramUserIDConfigured(channel *model.NotifyChannel, telegramUserID int) bool { + if channel == nil || telegramUserID == 0 { + return false + } + cfg := s.telegramChannelConfig(channel) + target := strconv.Itoa(telegramUserID) + for _, value := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) { + if value == target { + return true + } + } + if strings.TrimSpace(cfg["admin_user_ids"]) == "" && strings.TrimSpace(cfg["chat_id"]) == target { + return true + } + return false +} + +func (s *TelegramBotService) telegramChannelConfig(channel *model.NotifyChannel) map[string]string { + return telegramConfigFromChannel(s.crypto, channel) +} + +func telegramConfigFromChannel(crypto *CryptoService, channel *model.NotifyChannel) map[string]string { + if channel == nil { + return map[string]string{} + } + configStr := channel.Config + if crypto != nil && configStr != "" { + configStr = crypto.Decrypt(configStr) + } + var cfg map[string]string + if err := json.Unmarshal([]byte(configStr), &cfg); err != nil || cfg == nil { + return map[string]string{} + } + normalizeTelegramConfig(cfg) + return cfg +} + +func normalizeTelegramConfig(cfg map[string]string) { + if cfg == nil { + return + } + chatID := strings.TrimSpace(cfg["chat_id"]) + if chatID == "" { + return + } + if strings.HasPrefix(chatID, "-") { + if strings.TrimSpace(cfg["group_chat_id"]) == "" && strings.TrimSpace(cfg["channel_chat_id"]) == "" && strings.TrimSpace(cfg["command_chat_id"]) == "" { + cfg["group_chat_id"] = chatID + } + return + } + if strings.TrimSpace(cfg["admin_user_ids"]) == "" { + cfg["admin_user_ids"] = chatID + } +} diff --git a/internal/service/telegram_membership.go b/internal/service/telegram_membership.go new file mode 100644 index 0000000..37f3c30 --- /dev/null +++ b/internal/service/telegram_membership.go @@ -0,0 +1,122 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func (s *TelegramBotService) telegramUserIsAdmin(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool { + if s.telegramUserIDConfigured(channel, telegramUserID) { + return true + } + binding := s.telegramBinding(ctx, telegramUserID) + if binding == nil { + return false + } + user, err := s.repo.User.FindByID(ctx, binding.UserID) + return err == nil && user != nil && user.Role == "admin" && user.IsActive +} + +// telegramBindDecision 表示成员资格校验的三态结果:通过 / 明确不通过 / +// 无法验证(getChatMember 出错,如 Bot 不在群、群 ID 失效、网络或代理不可达)。 +// 区分「明确不是成员」和「查不了」,是为了避免把验证失败误报成「你不在群」。 +type telegramBindDecision int + +const ( + bindDenied telegramBindDecision = iota // 已查实:不在任何绑定群组/频道 + bindAllowed // 管理员,或查实是某绑定群组/频道成员 + bindUnverifiable // 配了群组/频道但 getChatMember 全部失败 +) + +// telegramMembership 表示单个 chat 的成员资格三态。 +type telegramMembership int + +const ( + membershipNo telegramMembership = iota // 查实不是成员(left/kicked 等) + membershipYes // 查实是成员 + membershipUnknown // getChatMember 出错,无法判定 +) + +func (s *TelegramBotService) telegramUserBindDecision(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) telegramBindDecision { + if telegramUserID == 0 || channel == nil { + return bindDenied + } + if s.telegramUserIDConfigured(channel, telegramUserID) { + return bindAllowed + } + chatIDs := s.telegramMembershipChatIDs(channel) + if len(chatIDs) == 0 { + return bindDenied + } + sawUnknown := false + for _, chatID := range chatIDs { + switch s.telegramChatMembership(ctx, channel, chatID, telegramUserID) { + case membershipYes: + return bindAllowed + case membershipUnknown: + sawUnknown = true + } + } + if sawUnknown { + return bindUnverifiable + } + return bindDenied +} + +// telegramUserCanBind 是 telegramUserBindDecision 的布尔包装,供尽力而为的场景 +// 使用(如私聊时挑选可用渠道):只有查实通过才返回 true。 +func (s *TelegramBotService) telegramUserCanBind(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool { + return s.telegramUserBindDecision(ctx, channel, telegramUserID) == bindAllowed +} + +// telegramBindRejectText 根据三态结果生成面向用户的提示。action 形如「兑换注册账号」 +// 「绑定媒体中心账号」。bindUnverifiable 时不再误导用户「你不在群」,而是提示 +// 管理员检查 Bot 权限与群组 ID。 +func telegramBindRejectText(decision telegramBindDecision, action string) string { + if decision == bindUnverifiable { + return fmt.Sprintf("暂时无法验证你的群组/频道成员身份,%s未成功。这通常是因为 Bot 未加入绑定群组、在频道中不是管理员,或群组 ID 配置有误(如超级群需带 -100 前缀)。请联系管理员检查 Bot 权限与「绑定群组/频道 ID」。", action) + } + return fmt.Sprintf("当前 Telegram 账号不在管理员配置的绑定群组/频道中,无法%s。请先加入管理员配置的群组或频道;如果尚未配置,请联系管理员。", action) +} + +func (s *TelegramBotService) telegramChatMembership(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) telegramMembership { + cfg := s.telegramChannelConfig(channel) + if strings.TrimSpace(cfg["bot_token"]) == "" || chatID == "" || telegramUserID == 0 { + return membershipUnknown + } + payload := map[string]interface{}{ + "chat_id": chatID, + "user_id": telegramUserID, + } + var result struct { + OK bool `json:"ok"` + Result struct { + Status string `json:"status"` + } `json:"result"` + } + if err := telegramPostJSONDecode(ctx, cfg, "getChatMember", payload, 15*time.Second, &result); err != nil { + s.log.Warn("telegram getChatMember failed", zap.String("chat_id", chatID), zap.Int("telegram_user_id", telegramUserID), zap.Error(sanitizeTelegramError(err))) + return membershipUnknown + } + if !result.OK { + return membershipUnknown + } + switch strings.ToLower(result.Result.Status) { + case "creator", "administrator", "member", "restricted": + return membershipYes + default: + return membershipNo + } +} + +// telegramUserIsChatMember 是 telegramChatMembership 的布尔包装,仅在查实是成员时 +// 返回 true(查不了也视为非成员,供尽力而为的场景使用)。 +func (s *TelegramBotService) telegramUserIsChatMember(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) bool { + return s.telegramChatMembership(ctx, channel, chatID, telegramUserID) == membershipYes +}