diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index 510b023..41e349f 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -347,8 +347,8 @@ func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, return telegramCommandReply{Text: "欢迎使用 MediaStationGo\n\n普通用户请先绑定账号:\n/start 用户名 密码\n或:/start 用户名-密码\n\n" + hint} } channel := s.findChannelForMessage(ctx, msg) - if !s.telegramUserCanBind(ctx, channel, msg.From.ID) { - return telegramCommandReply{Text: "当前 Telegram 账号不在管理员配置的绑定群组/频道中,无法绑定媒体中心账号。请先加入管理员配置的群组或频道;如果尚未配置,请联系管理员。"} + if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { + return telegramCommandReply{Text: telegramBindRejectText(dec, "绑定媒体中心账号")} } username, password := parseStartCredentials(args) if username == "" || password == "" { @@ -404,8 +404,8 @@ func (s *TelegramBotService) cmdRegister(ctx context.Context, channel *model.Not if channel == nil { channel = s.findChannelForMessage(ctx, msg) } - if !s.telegramUserCanBind(ctx, channel, msg.From.ID) { - return telegramCommandReply{Text: "当前 Telegram 账号不在管理员配置的绑定群组/频道中,无法注册账号。请先加入管理员配置的群组或频道;如果尚未配置,请联系管理员。"} + if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { + return telegramCommandReply{Text: telegramBindRejectText(dec, "注册账号")} } if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil { if user, _ := s.repo.User.FindByID(ctx, binding.UserID); user != nil { @@ -565,8 +565,8 @@ func telegramMgoAdminCommandHelp() string { // cmdStatus 处理 /status 命令。 func (s *TelegramBotService) cmdHideAdult(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { channel := s.findChannelForMessage(ctx, msg) - if !s.telegramUserCanBind(ctx, channel, msg.From.ID) { - return telegramCommandReply{Text: "当前 Telegram 账号不在管理员配置的绑定群组/频道中,无法使用成人目录隐藏开关。"} + if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { + return telegramCommandReply{Text: telegramBindRejectText(dec, "使用成人目录隐藏开关")} } binding := s.telegramBinding(ctx, msg.From.ID) if binding == nil { @@ -1289,34 +1289,72 @@ func (s *TelegramBotService) telegramChatAllowed(channel *model.NotifyChannel, c return strings.TrimSpace(cfg["chat_id"]) == target } -func (s *TelegramBotService) telegramUserCanBind(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool { +// 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 false + return bindDenied } if s.telegramUserIDConfigured(channel, telegramUserID) { - return true + return bindAllowed } - cfg := s.telegramChannelConfig(channel) - groupID := strings.TrimSpace(cfg["group_chat_id"]) - channelID := strings.TrimSpace(cfg["channel_chat_id"]) - if groupID == "" && channelID == "" { - return false + chatIDs := s.telegramMembershipChatIDs(channel) + if len(chatIDs) == 0 { + return bindDenied } - for _, chatID := range []string{groupID, channelID} { - if chatID == "" { - continue - } - if s.telegramUserIsChatMember(ctx, channel, chatID, telegramUserID) { - return true + sawUnknown := false + for _, chatID := range chatIDs { + switch s.telegramChatMembership(ctx, channel, chatID, telegramUserID) { + case membershipYes: + return bindAllowed + case membershipUnknown: + sawUnknown = true } } - return false + if sawUnknown { + return bindUnverifiable + } + return bindDenied } -func (s *TelegramBotService) telegramUserIsChatMember(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) bool { +// 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 false + return membershipUnknown } payload := map[string]interface{}{ "chat_id": chatID, @@ -1328,21 +1366,27 @@ func (s *TelegramBotService) telegramUserIsChatMember(ctx context.Context, chann Status string `json:"status"` } `json:"result"` } - if err := telegramPostJSONDecode(ctx, cfg, "getChatMember", payload, 8*time.Second, &result); err != nil { + 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 false + return membershipUnknown } if !result.OK { - return false + return membershipUnknown } switch strings.ToLower(result.Result.Status) { case "creator", "administrator", "member", "restricted": - return true + return membershipYes default: - return false + 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 diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go index 894accb..0a254f8 100644 --- a/internal/service/telegram_menu.go +++ b/internal/service/telegram_menu.go @@ -526,8 +526,8 @@ func (s *TelegramBotService) redeemRegisterFlow(ctx context.Context, channel *mo if channel == nil { channel = s.findChannelForMessage(ctx, msg) } - if !s.telegramUserCanBind(ctx, channel, msg.From.ID) { - return telegramCommandReply{Text: "当前 Telegram 账号不在管理员配置的绑定群组/频道中,无法兑换注册账号。请先加入管理员配置的群组或频道;如果尚未配置,请联系管理员。"} + if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { + return telegramCommandReply{Text: telegramBindRejectText(dec, "兑换注册账号")} } rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRegister) if rc == nil { diff --git a/internal/service/telegram_mgo_compat.go b/internal/service/telegram_mgo_compat.go index a4f91ce..7c75931 100644 --- a/internal/service/telegram_mgo_compat.go +++ b/internal/service/telegram_mgo_compat.go @@ -596,14 +596,16 @@ func (s *TelegramBotService) cmdMgoSyncGroup(ctx context.Context, channel *model if user == nil || UserIsProtectedAccount(ctx, s.repo, user) { continue } - member := false + // 仅当所有绑定群组/频道都「查实不是成员」时才判定为可清理; + // getChatMember 出错(membershipUnknown)时保守跳过,避免误删。 + confirmedNo := true for _, chatID := range chatIDs { - if s.telegramUserIsChatMember(ctx, channel, chatID, int(binding.TelegramUserID)) { - member = true + if s.telegramChatMembership(ctx, channel, chatID, int(binding.TelegramUserID)) != membershipNo { + confirmedNo = false break } } - if !member { + if confirmedNo { stale = append(stale, staleBinding{User: *user, Binding: binding}) } } @@ -634,7 +636,7 @@ func (s *TelegramBotService) telegramMembershipChatIDs(channel *model.NotifyChan cfg := s.telegramChannelConfig(channel) seen := map[string]struct{}{} var out []string - for _, key := range []string{"group_chat_id", "channel_chat_id"} { + for _, key := range []string{"group_chat_id", "channel_chat_id", "command_chat_id"} { value := strings.TrimSpace(cfg[key]) if value == "" { continue