Files
MeBox/internal/service/telegram_binding.go
T

471 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
var binding model.TelegramBinding
err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", int64(telegramUserID)).First(&binding).Error
if err != nil {
return nil
}
return &binding
}
func (s *TelegramBotService) unbindTelegramUser(ctx context.Context, telegramUserID int) error {
if s == nil || s.repo == nil || s.repo.DB == nil || telegramUserID == 0 {
return nil
}
return s.repo.DB.WithContext(ctx).Unscoped().
Where("telegram_user_id = ?", int64(telegramUserID)).
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 != "" {
name = "@" + strings.TrimSpace(msg.From.Username)
}
telegramUserID := int64(msg.From.ID)
return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing model.TelegramBinding
err := tx.Where("telegram_user_id = ?", telegramUserID).First(&existing).Error
if err == nil {
if err := s.replaceTelegramAccountBindingTx(ctx, tx, userID, telegramUserID); err != nil {
return err
}
if err := tx.Model(&existing).Updates(map[string]any{
"telegram_name": name,
"chat_id": telegramBindingChatIDForMessage(msg, &existing),
"user_id": userID,
}).Error; telegramBindingUniqueErr(err) {
return errTelegramAccountAlreadyBound
} else if err != nil {
return err
}
return nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := tx.Unscoped().Where("telegram_user_id = ?", telegramUserID).Delete(&model.TelegramBinding{}).Error; err != nil {
return err
}
if err := s.replaceTelegramAccountBindingTx(ctx, tx, userID, telegramUserID); err != nil {
return err
}
err = tx.Create(&model.TelegramBinding{
TelegramUserID: telegramUserID,
TelegramName: name,
ChatID: telegramBindingChatIDForMessage(msg, nil),
UserID: userID,
}).Error
if telegramBindingUniqueErr(err) {
return errTelegramAccountAlreadyBound
}
return err
})
}
func telegramBindingChatIDForMessage(msg *TelegramMessage, existing *model.TelegramBinding) int64 {
if msg == nil {
if existing != nil {
return existing.ChatID
}
return 0
}
if msg.Chat.Type == "" || msg.Chat.Type == "private" {
return int64(msg.Chat.ID)
}
if existing != nil && existing.ChatID > 0 {
return existing.ChatID
}
return int64(msg.From.ID)
}
func telegramPrivateChatIDFromBinding(binding model.TelegramBinding) int64 {
if binding.ChatID > 0 {
return binding.ChatID
}
return binding.TelegramUserID
}
func (s *TelegramBotService) replaceTelegramAccountBindingTx(ctx context.Context, tx *gorm.DB, userID string, telegramUserID int64) error {
return tx.WithContext(ctx).Unscoped().
Where("user_id = ? AND telegram_user_id <> ?", userID, telegramUserID).
Delete(&model.TelegramBinding{}).Error
}
func telegramBindingUniqueErr(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "idx_telegram_bindings_user_id_active") ||
strings.Contains(msg, "telegram_bindings.user_id") ||
(strings.Contains(msg, "unique") && strings.Contains(msg, "telegram_bindings"))
}
func parseStartCredentials(args []string) (string, string) {
if len(args) >= 2 {
return strings.TrimSpace(args[0]), strings.TrimSpace(strings.Join(args[1:], " "))
}
if len(args) == 1 {
raw := strings.TrimSpace(args[0])
for _, sep := range []string{"-", ":", ":"} {
if parts := strings.SplitN(raw, sep, 2); len(parts) == 2 {
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
}
}
return "", ""
}
func userNameOrFallback(user *model.User) string {
if user == nil || strings.TrimSpace(user.Username) == "" {
return "未知用户"
}
return user.Username
}