198 lines
6.0 KiB
Go
198 lines
6.0 KiB
Go
package reward
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
type tgConfig struct {
|
|
botUser, channel, botToken, webhookSecret string
|
|
}
|
|
|
|
// ChatMemberChecker 判定某 telegramID 是否是频道/群成员。默认实现打 Telegram Bot API,
|
|
// 测试注入 fake 以避免真实网络调用。
|
|
type ChatMemberChecker interface {
|
|
IsMember(ctx context.Context, channel, telegramID string) (bool, error)
|
|
}
|
|
|
|
// SetMemberChecker 注入自定义成员校验器(测试用);生产走 SetTelegram 设置的默认 apiChecker。
|
|
func (s *Service) SetMemberChecker(c ChatMemberChecker) { s.checker = c }
|
|
|
|
// SetTelegram 注入 TG 配置(经 Service.tg)。若尚未注入 checker(测试未 SetMemberChecker),
|
|
// 默认设为打 Telegram Bot API 的 apiChecker。
|
|
func (s *Service) SetTelegram(botUser, channel, botToken, webhookSecret string) {
|
|
s.tg = tgConfig{botUser: botUser, channel: channel, botToken: botToken, webhookSecret: webhookSecret}
|
|
if s.checker == nil {
|
|
s.checker = apiChecker{botToken: botToken, hc: &http.Client{Timeout: 8 * time.Second}}
|
|
}
|
|
}
|
|
|
|
// WebhookSecret 返回 TG webhook 校验用的 secret token。
|
|
func (s *Service) WebhookSecret() string { return s.tg.webhookSecret }
|
|
|
|
// Channel 返回配置的 TG 频道(@ 形式)。
|
|
func (s *Service) Channel() string { return s.tg.channel }
|
|
|
|
// SetRedis 注入 redis 客户端(经 Service.rdb);为 nil 时 token 走内存兜底(测试/未配 redis)。
|
|
func (s *Service) SetRedis(rdb *redis.Client) { s.rdb = rdb }
|
|
|
|
func randToken() string {
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// IssueTelegramToken 生成随机 token,Redis SETEX tg_bind:<token> 600 <userID>;
|
|
// Redis 为 nil 时用内存 map 兜底(测试/未配 redis)。
|
|
func (s *Service) IssueTelegramToken(ctx context.Context, userID int64) (string, error) {
|
|
tok := randToken()
|
|
if s.rdb != nil {
|
|
return tok, s.rdb.Set(ctx, "tg_bind:"+tok, userID, 10*time.Minute).Err()
|
|
}
|
|
s.memMu.Lock()
|
|
if s.memTok == nil {
|
|
s.memTok = map[string]int64{}
|
|
}
|
|
s.memTok[tok] = userID
|
|
s.memMu.Unlock()
|
|
return tok, nil
|
|
}
|
|
|
|
// ConsumeTelegramToken 一次性消费(GETDEL 语义)。
|
|
func (s *Service) ConsumeTelegramToken(ctx context.Context, token string) (int64, bool, error) {
|
|
if s.rdb != nil {
|
|
v, err := s.rdb.GetDel(ctx, "tg_bind:"+token).Result()
|
|
if err == redis.Nil {
|
|
return 0, false, nil
|
|
}
|
|
if err != nil {
|
|
return 0, false, err
|
|
}
|
|
id, _ := strconv.ParseInt(v, 10, 64)
|
|
return id, true, nil
|
|
}
|
|
s.memMu.Lock()
|
|
defer s.memMu.Unlock()
|
|
id, ok := s.memTok[token]
|
|
if ok {
|
|
delete(s.memTok, token)
|
|
}
|
|
return id, ok, nil
|
|
}
|
|
|
|
// ClaimResult 是 ClaimTelegram 的结果三态,供上层(handler)按态回不同文案。
|
|
type ClaimResult int
|
|
|
|
const (
|
|
ClaimGranted ClaimResult = iota // 发放成功
|
|
ClaimAlready // 已领取过(本账户或该 telegram_id)
|
|
ClaimNotMember // 不在频道
|
|
)
|
|
|
|
// ClaimTelegram: 真是频道成员则一次性发 TgDays 天(source='task'),唯一守卫防重复领取
|
|
// (同 userID 或同 telegramID 只能成功一次)。
|
|
func (s *Service) ClaimTelegram(ctx context.Context, userID int64, telegramID string) (ClaimResult, error) {
|
|
member, err := s.checker.IsMember(ctx, s.tg.channel, telegramID)
|
|
if err != nil {
|
|
return ClaimNotMember, err
|
|
}
|
|
if !member {
|
|
return ClaimNotMember, nil
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
|
if err != nil {
|
|
return ClaimNotMember, err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
now := s.now()
|
|
if err := s.st.InsertClaimTx(ctx, tx, userID, "telegram_join", telegramID, s.cfg.TgDays, now); err != nil {
|
|
if err == ErrClaimExists {
|
|
return ClaimAlready, nil // 已领过(本账户 or 该 tgid)
|
|
}
|
|
return ClaimNotMember, err
|
|
}
|
|
if _, _, err := s.g.GrantRewardTx(ctx, tx, userID, s.cfg.TgDays, "task", "task_telegram_join", "tg:"+telegramID); err != nil {
|
|
return ClaimNotMember, err
|
|
}
|
|
if s.noticer != nil {
|
|
zh, en := rewardNoticeTitles("task_tg", s.cfg.TgDays)
|
|
if err := s.noticer.InsertNoticeTx(ctx, tx, userID, "reward", zh, en, "", "", "", now); err != nil {
|
|
return ClaimNotMember, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return ClaimNotMember, err
|
|
}
|
|
return ClaimGranted, nil
|
|
}
|
|
|
|
// apiChecker 是 ChatMemberChecker 的默认生产实现:打 Telegram getChatMember。
|
|
type apiChecker struct {
|
|
botToken string
|
|
hc *http.Client
|
|
}
|
|
|
|
func (c apiChecker) IsMember(ctx context.Context, channel, telegramID string) (bool, error) {
|
|
u := "https://api.telegram.org/bot" + c.botToken + "/getChatMember"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
q := req.URL.Query()
|
|
q.Set("chat_id", channel)
|
|
q.Set("user_id", telegramID)
|
|
req.URL.RawQuery = q.Encode()
|
|
resp, err := c.hc.Do(req)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
OK bool `json:"ok"`
|
|
Result struct {
|
|
Status string `json:"status"`
|
|
} `json:"result"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return false, err
|
|
}
|
|
st := out.Result.Status
|
|
return out.OK && (st == "member" || st == "administrator" || st == "creator"), nil
|
|
}
|
|
|
|
// SendTelegram 打 Bot API sendMessage 给 chatID 发文案;失败只 log,不影响主流程。
|
|
func (s *Service) SendTelegram(ctx context.Context, chatID int64, text string) {
|
|
u := "https://api.telegram.org/bot" + s.tg.botToken + "/sendMessage"
|
|
body := url.Values{}
|
|
body.Set("chat_id", strconv.FormatInt(chatID, 10))
|
|
body.Set("text", text)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(body.Encode()))
|
|
if err != nil {
|
|
slog.Warn("reward: send telegram build request failed", "err", err)
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
hc := &http.Client{Timeout: 8 * time.Second}
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
slog.Warn("reward: send telegram failed", "err", err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
slog.Warn("reward: send telegram non-2xx", "status", resp.StatusCode)
|
|
}
|
|
}
|
|
|