mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
split telegram binding service
This commit is contained in:
@@ -2,155 +2,14 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"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 {
|
func (s *TelegramBotService) telegramBinding(ctx context.Context, telegramUserID int) *model.TelegramBinding {
|
||||||
if telegramUserID == 0 {
|
if telegramUserID == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -172,196 +31,6 @@ func (s *TelegramBotService) unbindTelegramUser(ctx context.Context, telegramUse
|
|||||||
Delete(&model.TelegramBinding{}).Error
|
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 {
|
func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *TelegramMessage, userID string) error {
|
||||||
name := strings.TrimSpace(msg.From.FirstName)
|
name := strings.TrimSpace(msg.From.FirstName)
|
||||||
if msg.From.Username != "" {
|
if msg.From.Username != "" {
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user