feat: refactor payment system with multi-currency support and TRX native transfer detection
- Add multi-currency exchange rate API integration (GetRateForCoin) - Implement native TRX transfer detection alongside TRC20 USDT transfers - Replace deprecated Tronscan API with Trongrid API for better reliability - Add TRON Grid API key configuration support - Refactor order service to support multiple tokens and currencies - Update wallet address locking/unlocking to track token type - Add base58 and gjson dependencies for crypto operations - Optimize SQLite connection with WAL mode and busy timeout - Remove deprecated USDT rate job (replaced by dynamic rate API) - Update order data model to include currency and receive address fields - Enhance TRC20 callback with improved error handling and logging
This commit is contained in:
@@ -1,161 +1,347 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/spf13/viper"
|
||||
tron "github.com/assimon/luuu/crypto"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/assimon/luuu/mq"
|
||||
"github.com/assimon/luuu/mq/handle"
|
||||
"github.com/assimon/luuu/telegram"
|
||||
"github.com/assimon/luuu/util/http_client"
|
||||
"github.com/assimon/luuu/util/json"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/assimon/luuu/util/math"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/gookit/goutil/stdutil"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
const UsdtTrc20ApiUri = "https://apilist.tronscanapi.com/api/transfer/trc20"
|
||||
|
||||
type UsdtTrc20Resp struct {
|
||||
PageSize int `json:"page_size"`
|
||||
Code int `json:"code"`
|
||||
Data []Data `json:"data"`
|
||||
}
|
||||
|
||||
type TokenInfo struct {
|
||||
TokenID string `json:"tokenId"`
|
||||
TokenAbbr string `json:"tokenAbbr"`
|
||||
TokenName string `json:"tokenName"`
|
||||
TokenDecimal int `json:"tokenDecimal"`
|
||||
TokenCanShow int `json:"tokenCanShow"`
|
||||
TokenType string `json:"tokenType"`
|
||||
TokenLogo string `json:"tokenLogo"`
|
||||
TokenLevel string `json:"tokenLevel"`
|
||||
IssuerAddr string `json:"issuerAddr"`
|
||||
Vip bool `json:"vip"`
|
||||
}
|
||||
|
||||
type Data struct {
|
||||
Amount string `json:"amount"`
|
||||
ApprovalAmount string `json:"approval_amount"`
|
||||
BlockTimestamp int64 `json:"block_timestamp"`
|
||||
Block int `json:"block"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Hash string `json:"hash"`
|
||||
Confirmed int `json:"confirmed"`
|
||||
ContractType string `json:"contract_type"`
|
||||
ContracTType int `json:"contractType"`
|
||||
Revert int `json:"revert"`
|
||||
ContractRet string `json:"contract_ret"`
|
||||
EventType string `json:"event_type"`
|
||||
IssueAddress string `json:"issue_address"`
|
||||
Decimals int `json:"decimals"`
|
||||
TokenName string `json:"token_name"`
|
||||
ID string `json:"id"`
|
||||
Direction int `json:"direction"`
|
||||
}
|
||||
const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||
|
||||
// Trc20CallBack trc20回调
|
||||
func Trc20CallBack(token string, wg *sync.WaitGroup) {
|
||||
func Trc20CallBack(address string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
var innerWg sync.WaitGroup
|
||||
innerWg.Add(2)
|
||||
go checkTrxTransfers(address, &innerWg)
|
||||
go checkTrc20Transfers(address, &innerWg)
|
||||
innerWg.Wait()
|
||||
}
|
||||
|
||||
// checkTrxTransfers 查询 TRX 原生转账
|
||||
func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] panic recovered: %v", address, err)
|
||||
}
|
||||
}()
|
||||
|
||||
client := http_client.GetHttpClient()
|
||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
||||
endTime := carbon.Now().TimestampMilli()
|
||||
|
||||
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address)
|
||||
log.Sugar.Debugf("checkTrxTransfers URL: %s, from %d to %d", url, startTime, endTime)
|
||||
|
||||
resp, err := client.R().SetQueryParams(map[string]string{
|
||||
"sort": "-timestamp",
|
||||
"limit": "50",
|
||||
"start": "0",
|
||||
"direction": "2",
|
||||
"db_version": "1",
|
||||
"trc20Id": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
|
||||
"address": token,
|
||||
"start_timestamp": stdutil.ToString(startTime),
|
||||
"end_timestamp": stdutil.ToString(endTime),
|
||||
}).Get(UsdtTrc20ApiUri)
|
||||
"order_by": "block_timestamp,desc",
|
||||
"limit": "100",
|
||||
// "only_confirmed": "true",
|
||||
"only_to": "true",
|
||||
"min_timestamp": stdutil.ToString(startTime),
|
||||
"max_timestamp": stdutil.ToString(endTime),
|
||||
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
panic(err)
|
||||
panic(fmt.Sprintf("TRX API returned status %d", resp.StatusCode()))
|
||||
}
|
||||
var trc20Resp UsdtTrc20Resp
|
||||
err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
log.Sugar.Debugf("Raw request URL: %s", resp.Request.URL)
|
||||
|
||||
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
||||
if !success {
|
||||
panic("TRX API response indicates failure")
|
||||
}
|
||||
if trc20Resp.PageSize <= 0 {
|
||||
dataArray := gjson.GetBytes(resp.Body(), "data").Array()
|
||||
log.Sugar.Infof("[TRX][%s] API返回 %d 条交易记录", address, len(dataArray))
|
||||
if len(dataArray) == 0 {
|
||||
log.Sugar.Infof("[TRX][%s] 没有找到任何交易记录,跳过", address)
|
||||
return
|
||||
}
|
||||
for _, transfer := range trc20Resp.Data {
|
||||
if transfer.To != token || transfer.ContractRet != "SUCCESS" {
|
||||
|
||||
for i, transfer := range dataArray {
|
||||
transferType := transfer.Get("raw_data.contract.0.type").String()
|
||||
if transferType != "TransferContract" {
|
||||
log.Sugar.Debugf("[TRX][%s] 第%d条: 类型=%s, 非TransferContract, 跳过", address, i, transferType)
|
||||
continue
|
||||
}
|
||||
decimalQuant, err := decimal.NewFromString(transfer.Amount)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
contractRet := transfer.Get("ret.0.contractRet").String()
|
||||
if contractRet != "SUCCESS" {
|
||||
log.Sugar.Infof("[TRX][%s] 第%d条: contractRet=%s, 非SUCCESS, 跳过", address, i, contractRet)
|
||||
continue
|
||||
}
|
||||
decimalDivisor := decimal.NewFromFloat(1000000)
|
||||
amount := decimalQuant.Div(decimalDivisor).InexactFloat64()
|
||||
tradeId, err := data.GetTradeIdByWalletAddressAndAmount(token, amount)
|
||||
|
||||
toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
|
||||
toBytes, err := hex.DecodeString(toAddressHex)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] 第%d条: 解码地址失败: %v", address, i, err)
|
||||
continue
|
||||
}
|
||||
toAddress := tron.EncodeCheck(toBytes)
|
||||
if toAddress != address {
|
||||
log.Sugar.Debugf("[TRX][%s] 第%d条: 目标地址=%s, 不匹配, 跳过", address, i, toAddress)
|
||||
continue
|
||||
}
|
||||
|
||||
rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String()
|
||||
decimalQuant, err := decimal.NewFromString(rawAmount)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] 第%d条: 解析金额失败: %v", address, i, err)
|
||||
continue
|
||||
}
|
||||
divisor := decimal.NewFromInt(1000000)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
|
||||
txID := transfer.Get("txID").String()
|
||||
log.Sugar.Infof("[TRX][%s] 第%d条: txID=%s, rawAmount=%s, 解析金额=%.2f", address, i, txID, rawAmount, amount)
|
||||
if amount <= 0 {
|
||||
log.Sugar.Infof("[TRX][%s] 第%d条: 金额<=0, 跳过", address, i)
|
||||
continue
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("wallet:%s_%s_%v", address, "TRX", amount)
|
||||
log.Sugar.Infof("[TRX][%s] 第%d条: 查询Redis匹配, cacheKey=%s", address, i, cacheKey)
|
||||
tradeId, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if tradeId == "" {
|
||||
log.Sugar.Infof("[TRX][%s] 第%d条: Redis未匹配到订单, 金额=%.2f, 跳过", address, i, amount)
|
||||
continue
|
||||
}
|
||||
log.Sugar.Infof("[TRX][%s] 第%d条: Redis匹配到订单! tradeId=%s, 金额=%.2f", address, i, tradeId, amount)
|
||||
order, err := data.GetOrderInfoByTradeId(tradeId)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// 区块的确认时间必须在订单创建时间之后
|
||||
log.Sugar.Infof("[TRX][%s] 查到订单: tradeId=%s, orderId=%s, status=%d, amount=%.2f, actualAmount=%.2f", address, order.TradeId, order.OrderId, order.Status, order.Amount, order.ActualAmount)
|
||||
|
||||
createTime := order.CreatedAt.TimestampMilli()
|
||||
if transfer.BlockTimestamp < createTime {
|
||||
blockTimestamp := transfer.Get("block_timestamp").Int()
|
||||
log.Sugar.Infof("[TRX][%s] 时间校验: blockTimestamp=%d, orderCreateTime=%d", address, blockTimestamp, createTime)
|
||||
if blockTimestamp < createTime {
|
||||
log.Sugar.Errorf("[TRX][%s] 区块时间早于订单创建时间,无法匹配! blockTimestamp=%d < createTime=%d", address, blockTimestamp, createTime)
|
||||
panic("Orders cannot actually be matched")
|
||||
}
|
||||
// 到这一步就完全算是支付成功了
|
||||
|
||||
transferHash := transfer.Get("txID").String()
|
||||
log.Sugar.Infof("[TRX][%s] 开始处理订单: tradeId=%s, hash=%s, amount=%.2f", address, tradeId, transferHash, amount)
|
||||
|
||||
req := &request.OrderProcessingRequest{
|
||||
Token: token,
|
||||
ReceiveAddress: address,
|
||||
Token: "TRX",
|
||||
TradeId: tradeId,
|
||||
Amount: amount,
|
||||
BlockTransactionId: transfer.Hash,
|
||||
BlockTransactionId: transferHash,
|
||||
}
|
||||
err = OrderProcessing(req)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] OrderProcessing失败: tradeId=%s, err=%v", address, tradeId, err)
|
||||
panic(err)
|
||||
}
|
||||
// 回调队列
|
||||
log.Sugar.Infof("[TRX][%s] OrderProcessing成功: tradeId=%s", address, tradeId)
|
||||
|
||||
orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order)
|
||||
orderNoticeMaxRetry := viper.GetInt("order_notice_max_retry")
|
||||
mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(orderNoticeMaxRetry),
|
||||
asynq.Retention(config.GetOrderExpirationTimeDuration()),
|
||||
)
|
||||
// mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(5))
|
||||
// 发送机器人消息
|
||||
log.Sugar.Infof("[TRX][%s] 回调队列已入队: tradeId=%s", address, tradeId)
|
||||
|
||||
msgTpl := `
|
||||
<b>📢📢有新的交易支付成功!</b>
|
||||
<pre>交易号:%s</pre>
|
||||
<pre>订单号:%s</pre>
|
||||
<pre>请求支付金额:%f cny</pre>
|
||||
<pre>实际支付金额:%f usdt</pre>
|
||||
<pre>钱包地址:%s</pre>
|
||||
<pre>订单创建时间:%s</pre>
|
||||
<pre>支付成功时间:%s</pre>
|
||||
🎉 <b>收款成功通知</b>
|
||||
|
||||
💰 <b>金额信息</b>
|
||||
├ 订单金额:<code>%.2f %s</code>
|
||||
└ 实际到账:<code>%.2f %s</code>
|
||||
|
||||
📋 <b>订单信息</b>
|
||||
├ 交易号:<code>%s</code>
|
||||
├ 订单号:<code>%s</code>
|
||||
└ 钱包地址:<code>%s</code>
|
||||
|
||||
⏰ <b>时间信息</b>
|
||||
├ 创建时间:%s
|
||||
└ 支付时间:%s
|
||||
`
|
||||
msg := fmt.Sprintf(msgTpl, order.TradeId, order.OrderId, order.Amount, order.ActualAmount, order.Token, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString())
|
||||
msg := fmt.Sprintf(msgTpl, order.Amount, strings.ToUpper(order.Currency), order.ActualAmount, strings.ToUpper(order.Token), order.TradeId, order.OrderId, order.ReceiveAddress, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString())
|
||||
log.Sugar.Infof("[TRX][%s] 准备发送Telegram通知: tradeId=%s, orderId=%s", address, tradeId, order.OrderId)
|
||||
telegram.SendToBot(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTrc20Transfers 查询 TRC20 (USDT) 转账
|
||||
func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Errorf("[TRC20][%s] panic recovered: %v", address, err)
|
||||
}
|
||||
}()
|
||||
|
||||
client := http_client.GetHttpClient()
|
||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
||||
endTime := carbon.Now().TimestampMilli()
|
||||
|
||||
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions/trc20", address)
|
||||
log.Sugar.Debugf("checkTrc20Transfers URL: %s, from %d to %d", url, startTime, endTime)
|
||||
|
||||
resp, err := client.R().SetQueryParams(map[string]string{
|
||||
"order_by": "block_timestamp,desc",
|
||||
"limit": "100",
|
||||
// "only_confirmed": "true",
|
||||
"only_to": "true",
|
||||
"min_timestamp": stdutil.ToString(startTime),
|
||||
"max_timestamp": stdutil.ToString(endTime),
|
||||
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
panic(fmt.Sprintf("TRC20 API returned status %d", resp.StatusCode()))
|
||||
}
|
||||
|
||||
log.Sugar.Debugf("Raw request URL: %s", resp.Request.URL)
|
||||
|
||||
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
||||
if !success {
|
||||
panic("TRC20 API response indicates failure")
|
||||
}
|
||||
dataArray := gjson.GetBytes(resp.Body(), "data").Array()
|
||||
log.Sugar.Infof("[TRC20][%s] API返回 %d 条交易记录", address, len(dataArray))
|
||||
if len(dataArray) == 0 {
|
||||
log.Sugar.Infof("[TRC20][%s] 没有找到任何交易记录,跳过", address)
|
||||
return
|
||||
}
|
||||
|
||||
for i, transfer := range dataArray {
|
||||
// 只处理 USDT
|
||||
tokenAddress := transfer.Get("token_info.address").String()
|
||||
if tokenAddress != TRC20_USDT_ID {
|
||||
log.Sugar.Debugf("[TRC20][%s] 第%d条: tokenAddress=%s, 非USDT, 跳过", address, i, tokenAddress)
|
||||
continue
|
||||
}
|
||||
|
||||
to := transfer.Get("to").String()
|
||||
if to != address {
|
||||
log.Sugar.Debugf("[TRC20][%s] 第%d条: to=%s, 不匹配, 跳过", address, i, to)
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析金额: value / 10^decimals
|
||||
valueStr := transfer.Get("value").String()
|
||||
decimalQuant, err := decimal.NewFromString(valueStr)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRC20][%s] 第%d条: 解析value失败: %v", address, i, err)
|
||||
continue
|
||||
}
|
||||
tokenDecimals := transfer.Get("token_info.decimals").Int()
|
||||
divisor := decimal.New(1, int32(tokenDecimals)) // 10^decimals
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
|
||||
txID := transfer.Get("transaction_id").String()
|
||||
log.Sugar.Infof("[TRC20][%s] 第%d条: txID=%s, value=%s, decimals=%d, 解析金额=%.2f", address, i, txID, valueStr, tokenDecimals, amount)
|
||||
if amount <= 0 {
|
||||
log.Sugar.Infof("[TRC20][%s] 第%d条: 金额<=0, 跳过", address, i)
|
||||
continue
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("wallet:%s_%s_%v", address, "USDT", amount)
|
||||
log.Sugar.Infof("[TRC20][%s] 第%d条: 查询Redis匹配, cacheKey=%s", address, i, cacheKey)
|
||||
tradeId, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "USDT", amount)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if tradeId == "" {
|
||||
log.Sugar.Infof("[TRC20][%s] 第%d条: Redis未匹配到订单, 金额=%.2f, 跳过", address, i, amount)
|
||||
continue
|
||||
}
|
||||
log.Sugar.Infof("[TRC20][%s] 第%d条: Redis匹配到订单! tradeId=%s, 金额=%.2f", address, i, tradeId, amount)
|
||||
order, err := data.GetOrderInfoByTradeId(tradeId)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
log.Sugar.Infof("[TRC20][%s] 查到订单: tradeId=%s, orderId=%s, status=%d, amount=%.2f, actualAmount=%.2f", address, order.TradeId, order.OrderId, order.Status, order.Amount, order.ActualAmount)
|
||||
|
||||
createTime := order.CreatedAt.TimestampMilli()
|
||||
blockTimestamp := transfer.Get("block_timestamp").Int()
|
||||
log.Sugar.Infof("[TRC20][%s] 时间校验: blockTimestamp=%d, orderCreateTime=%d", address, blockTimestamp, createTime)
|
||||
if blockTimestamp < createTime {
|
||||
log.Sugar.Errorf("[TRC20][%s] 区块时间早于订单创建时间,无法匹配! blockTimestamp=%d < createTime=%d", address, blockTimestamp, createTime)
|
||||
panic("Orders cannot actually be matched")
|
||||
}
|
||||
|
||||
transferHash := transfer.Get("transaction_id").String()
|
||||
log.Sugar.Infof("[TRC20][%s] 开始处理订单: tradeId=%s, hash=%s, amount=%.2f", address, tradeId, transferHash, amount)
|
||||
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: address,
|
||||
Token: "USDT",
|
||||
TradeId: tradeId,
|
||||
Amount: amount,
|
||||
BlockTransactionId: transferHash,
|
||||
}
|
||||
err = OrderProcessing(req)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRC20][%s] OrderProcessing失败: tradeId=%s, err=%v", address, tradeId, err)
|
||||
panic(err)
|
||||
}
|
||||
log.Sugar.Infof("[TRC20][%s] OrderProcessing成功: tradeId=%s", address, tradeId)
|
||||
|
||||
orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order)
|
||||
orderNoticeMaxRetry := viper.GetInt("order_notice_max_retry")
|
||||
mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(orderNoticeMaxRetry),
|
||||
asynq.Retention(config.GetOrderExpirationTimeDuration()),
|
||||
)
|
||||
log.Sugar.Infof("[TRC20][%s] 回调队列已入队: tradeId=%s", address, tradeId)
|
||||
|
||||
msgTpl := `
|
||||
🎉 <b>收款成功通知</b>
|
||||
|
||||
💰 <b>金额信息</b>
|
||||
├ 订单金额:<code>%.2f %s</code>
|
||||
└ 实际到账:<code>%.2f %s</code>
|
||||
|
||||
📋 <b>订单信息</b>
|
||||
├ 交易号:<code>%s</code>
|
||||
├ 订单号:<code>%s</code>
|
||||
└ 钱包地址:<code>%s</code>
|
||||
|
||||
⏰ <b>时间信息</b>
|
||||
├ 创建时间:%s
|
||||
└ 支付时间:%s
|
||||
`
|
||||
msg := fmt.Sprintf(msgTpl, order.Amount, strings.ToUpper(order.Currency), order.ActualAmount, strings.ToUpper(order.Token), order.TradeId, order.OrderId, order.ReceiveAddress, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString())
|
||||
log.Sugar.Infof("[TRC20][%s] 准备发送Telegram通知: tradeId=%s, orderId=%s", address, tradeId, order.OrderId)
|
||||
telegram.SendToBot(msg)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user