feat: add telegram bot (#71)

This commit is contained in:
Buer 2024-02-23 18:24:25 +08:00 committed by GitHub
parent 43b4ee37d9
commit e90f4c99fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1726 additions and 29 deletions

View File

@ -0,0 +1,34 @@
package telegram
import (
"one-api/common"
"strings"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func commandAffStart(b *gotgbot.Bot, ctx *ext.Context) error {
user := getBindUser(b, ctx)
if user == nil {
return nil
}
if user.AffCode == "" {
user.AffCode = common.GetRandomString(4)
if err := user.Update(false); err != nil {
ctx.EffectiveMessage.Reply(b, "系统错误,请稍后再试", nil)
return nil
}
}
messae := "您可以通过分享您的邀请码来邀请朋友,每次成功邀请将获得奖励。\n\n您的邀请码是: " + user.AffCode
if common.ServerAddress != "" {
serverAddress := strings.TrimSuffix(common.ServerAddress, "/")
messae += "\n\n页面地址" + serverAddress + "/register?aff=" + user.AffCode
}
ctx.EffectiveMessage.Reply(b, messae, nil)
return nil
}

View File

@ -0,0 +1,92 @@
package telegram
import (
"fmt"
"net/url"
"one-api/common"
"one-api/model"
"strings"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func commandApikeyStart(b *gotgbot.Bot, ctx *ext.Context) error {
user := getBindUser(b, ctx)
if user == nil {
return nil
}
message, pageParams := getApikeyList(user.Id, 1)
if pageParams == nil {
_, err := ctx.EffectiveMessage.Reply(b, message, nil)
if err != nil {
return fmt.Errorf("failed to send APIKEY message: %w", err)
}
return nil
}
_, err := ctx.EffectiveMessage.Reply(b, message, &gotgbot.SendMessageOpts{
ParseMode: "MarkdownV2",
ReplyMarkup: getPaginationInlineKeyboard(pageParams.key, pageParams.page, pageParams.total),
})
if err != nil {
return fmt.Errorf("failed to send APIKEY message: %w", err)
}
return nil
}
func getApikeyList(userId, page int) (message string, pageParams *paginationParams) {
genericParams := &model.GenericParams{
PaginationParams: model.PaginationParams{
Page: page,
Size: 5,
},
}
list, err := model.GetUserTokensList(userId, genericParams)
if err != nil {
return "系统错误,请稍后再试", nil
}
if list.Data == nil || len(*list.Data) == 0 {
return "找不到令牌", nil
}
chatUrlTmp := ""
if common.ServerAddress != "" {
chatUrlTmp = getChatUrl()
}
message = "点击令牌可复制:\n"
for _, token := range *list.Data {
message += fmt.Sprintf("*%s* : `%s`\n", escapeText(token.Name, "MarkdownV2"), token.Key)
if chatUrlTmp != "" {
message += strings.ReplaceAll(chatUrlTmp, `setToken`, token.Key)
}
message += "\n"
}
return message, getPageParams("apikey", page, genericParams.Size, int(list.TotalCount))
}
func getChatUrl() string {
serverAddress := strings.TrimSuffix(common.ServerAddress, "/")
chatNextUrl := fmt.Sprintf(`{"key":"setToken","url":"%s"}`, serverAddress)
chatNextUrl = "https://chat.oneapi.pro/#/?settings=" + url.QueryEscape(chatNextUrl)
if common.ChatLink != "" {
chatLink := strings.TrimSuffix(common.ChatLink, "/")
chatNextUrl = strings.ReplaceAll(chatNextUrl, `https://chat.oneapi.pro`, chatLink)
}
jumpUrl := fmt.Sprintf(`%s/jump?url=`, serverAddress)
amaUrl := jumpUrl + url.QueryEscape(fmt.Sprintf(`ama://set-api-key?server=%s&key=setToken`, serverAddress))
openCatUrl := jumpUrl + url.QueryEscape(fmt.Sprintf(`opencat://team/join?domain=%s&token=setToken`, serverAddress))
return fmt.Sprintf("[Next Chat](%s) [AMA](%s) [OpenCat](%s)\n", chatNextUrl, amaUrl, openCatUrl)
}

View File

@ -0,0 +1,28 @@
package telegram
import (
"fmt"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func commandBalanceStart(b *gotgbot.Bot, ctx *ext.Context) error {
user := getBindUser(b, ctx)
if user == nil {
return nil
}
quota := fmt.Sprintf("%.2f", float64(user.Quota)/500000)
usedQuota := fmt.Sprintf("%.2f", float64(user.UsedQuota)/500000)
_, err := ctx.EffectiveMessage.Reply(b, fmt.Sprintf("<b>余额:</b> $%s \n<b>已用:</b> $%s", quota, usedQuota), &gotgbot.SendMessageOpts{
ParseMode: "html",
})
if err != nil {
return fmt.Errorf("failed to send balance message: %w", err)
}
return err
}

View File

@ -0,0 +1,89 @@
package telegram
import (
"fmt"
"one-api/model"
"strings"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers"
)
func commandBindInit() (handler ext.Handler) {
return handlers.NewConversation(
[]ext.Handler{handlers.NewCommand("bind", commandBindStart)},
map[string][]ext.Handler{
"token": {handlers.NewMessage(noCommands, commandBindToken)},
},
cancelConversationOpts(),
)
}
func commandBindStart(b *gotgbot.Bot, ctx *ext.Context) error {
user := getBindUser(b, ctx)
if user != nil {
ctx.EffectiveMessage.Reply(b, "您的账户已绑定,请解邦后再试", nil)
return handlers.EndConversation()
}
_, err := ctx.EffectiveMessage.Reply(b, "请输入你的访问令牌", &gotgbot.SendMessageOpts{
ParseMode: "html",
ReplyMarkup: cancelConversationInlineKeyboard(),
})
if err != nil {
return fmt.Errorf("failed to send bind start message: %w", err)
}
return handlers.NextConversationState("token")
}
func commandBindToken(b *gotgbot.Bot, ctx *ext.Context) error {
tgUserId := getTGUserId(b, ctx)
if tgUserId == 0 {
return handlers.EndConversation()
}
input := ctx.EffectiveMessage.Text
// 去除input前后空格
input = strings.TrimSpace(input)
user := model.ValidateAccessToken(input)
if user == nil {
// If the number is not valid, try again!
ctx.EffectiveMessage.Reply(b, "Token 错误,请重试", &gotgbot.SendMessageOpts{
ParseMode: "html",
ReplyMarkup: cancelConversationInlineKeyboard(),
})
// We try the age handler again
return handlers.NextConversationState("token")
}
if user.TelegramId != 0 {
ctx.EffectiveMessage.Reply(b, "您的账户已绑定,请解邦后再试", nil)
return handlers.EndConversation()
}
// 查询该tg用户是否已经绑定其他账户
if model.IsTelegramIdAlreadyTaken(tgUserId) {
ctx.EffectiveMessage.Reply(b, "该TG已绑定其他账户请解邦后再试", nil)
return handlers.EndConversation()
}
// 绑定
updateUser := model.User{
Id: user.Id,
TelegramId: tgUserId,
}
err := updateUser.Update(false)
if err != nil {
ctx.EffectiveMessage.Reply(b, "绑定失败,请稍后再试", nil)
return handlers.EndConversation()
}
_, err = ctx.EffectiveMessage.Reply(b, "绑定成功", nil)
if err != nil {
return fmt.Errorf("failed to send bind token message: %w", err)
}
return handlers.EndConversation()
}

View File

@ -0,0 +1,54 @@
package telegram
import (
"fmt"
"html"
"one-api/model"
"strings"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func commandCustom(b *gotgbot.Bot, ctx *ext.Context) error {
command := strings.TrimSpace(ctx.EffectiveMessage.Text)
// 去除/
command = strings.TrimPrefix(command, "/")
menu, err := model.GetTelegramMenuByCommand(command)
if err != nil {
ctx.EffectiveMessage.Reply(b, "系统错误,请稍后再试", nil)
return nil
}
if menu == nil {
ctx.EffectiveMessage.Reply(b, "未找到该命令", nil)
return nil
}
_, err = b.SendMessage(ctx.EffectiveSender.Id(), menu.ReplyMessage, &gotgbot.SendMessageOpts{
ParseMode: menu.ParseMode,
})
if err != nil {
return fmt.Errorf("failed to send %s message: %w", command, err)
}
return nil
}
func escapeText(text, parseMode string) string {
switch parseMode {
case "MarkdownV2":
// Characters that need to be escaped in MarkdownV2 mode
chars := []string{"_", "*", "[", "]", "(", ")", "~", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"}
for _, char := range chars {
text = strings.ReplaceAll(text, char, "\\"+char)
}
case "HTML":
// Escape HTML special characters
text = html.EscapeString(text)
// Markdown mode does not require escaping
}
return text
}

View File

@ -0,0 +1,57 @@
package telegram
import (
"fmt"
"one-api/model"
"strings"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers"
)
func commandRechargeInit() (handler ext.Handler) {
return handlers.NewConversation(
[]ext.Handler{handlers.NewCommand("recharge", commandRechargeStart)},
map[string][]ext.Handler{
"recharge_token": {handlers.NewMessage(noCommands, commandRechargeToken)},
},
cancelConversationOpts(),
)
}
func commandRechargeStart(b *gotgbot.Bot, ctx *ext.Context) error {
_, err := ctx.EffectiveMessage.Reply(b, "请输入你的兑换码", &gotgbot.SendMessageOpts{
ParseMode: "html",
ReplyMarkup: cancelConversationInlineKeyboard(),
})
if err != nil {
return fmt.Errorf("failed to send recharge start message: %w", err)
}
return handlers.NextConversationState("recharge_token")
}
func commandRechargeToken(b *gotgbot.Bot, ctx *ext.Context) error {
user := getBindUser(b, ctx)
if user == nil {
return handlers.EndConversation()
}
input := ctx.EffectiveMessage.Text
// 去除input前后空格
input = strings.TrimSpace(input)
quota, err := model.Redeem(input, user.Id)
if err != nil {
ctx.EffectiveMessage.Reply(b, "充值失败:"+err.Error(), nil)
return handlers.EndConversation()
}
money := fmt.Sprintf("%.2f", float64(quota)/500000)
_, err = ctx.EffectiveMessage.Reply(b, fmt.Sprintf("成功充值 $%s ", money), nil)
if err != nil {
return fmt.Errorf("failed to send recharge token message: %w", err)
}
return handlers.EndConversation()
}

View File

@ -0,0 +1,29 @@
package telegram
import (
"one-api/model"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers"
)
func commandUnbindStart(b *gotgbot.Bot, ctx *ext.Context) error {
user := getBindUser(b, ctx)
if user == nil {
return nil
}
updateUser := map[string]interface{}{
"telegram_id": 0,
}
err := model.UpdateUser(user.Id, updateUser)
if err != nil {
ctx.EffectiveMessage.Reply(b, "绑定失败,请稍后再试", nil)
return handlers.EndConversation()
}
ctx.EffectiveMessage.Reply(b, "解邦成功", nil)
return nil
}

220
common/telegram/common.go Normal file
View File

@ -0,0 +1,220 @@
package telegram
import (
"errors"
"fmt"
"one-api/common"
"one-api/model"
"os"
"strings"
"time"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/callbackquery"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/message"
)
var TGupdater *ext.Updater
var TGBot *gotgbot.Bot
var TGDispatcher *ext.Dispatcher
var TGWebHookSecret = ""
var TGEnabled = false
func InitTelegramBot() {
if TGEnabled {
common.SysLog("Telegram bot has been started")
return
}
if os.Getenv("TG_BOT_API_KEY") == "" {
common.SysLog("Telegram bot is not enabled")
return
}
var err error
TGBot, err = gotgbot.NewBot(os.Getenv("TG_BOT_API_KEY"), nil)
if err != nil {
common.SysLog("failed to create new telegram bot: " + err.Error())
return
}
TGDispatcher = setDispatcher()
TGupdater = ext.NewUpdater(TGDispatcher, nil)
StartTelegramBot()
}
func StartTelegramBot() {
if os.Getenv("TG_WEBHOOK_SECRET") != "" {
if common.ServerAddress == "" {
common.SysLog("Telegram bot is not enabled: Server address is not set")
StopTelegramBot()
return
}
TGWebHookSecret = os.Getenv("TG_WEBHOOK_SECRET")
serverAddress := strings.TrimSuffix(common.ServerAddress, "/")
urlPath := fmt.Sprintf("/api/telegram/%s", os.Getenv("TG_BOT_API_KEY"))
webHookOpts := &ext.AddWebhookOpts{
SecretToken: TGWebHookSecret,
}
err := TGupdater.AddWebhook(TGBot, urlPath, webHookOpts)
if err != nil {
common.SysLog("Telegram bot failed to add webhook:" + err.Error())
return
}
err = TGupdater.SetAllBotWebhooks(serverAddress, &gotgbot.SetWebhookOpts{
MaxConnections: 100,
DropPendingUpdates: true,
SecretToken: TGWebHookSecret,
})
if err != nil {
common.SysLog("Telegram bot failed to set webhook:" + err.Error())
return
}
} else {
err := TGupdater.StartPolling(TGBot, &ext.PollingOpts{
EnableWebhookDeletion: true,
DropPendingUpdates: true,
GetUpdatesOpts: &gotgbot.GetUpdatesOpts{
Timeout: 9,
RequestOpts: &gotgbot.RequestOpts{
Timeout: time.Second * 10,
},
},
})
if err != nil {
common.SysLog("Telegram bot failed to start polling:" + err.Error())
}
}
// Idle, to keep updates coming in, and avoid bot stopping.
go TGupdater.Idle()
common.SysLog(fmt.Sprintf("Telegram bot %s has been started...:", TGBot.User.Username))
TGEnabled = true
}
func ReloadMenuAndCommands() error {
if !TGEnabled || TGupdater == nil {
return errors.New("telegram bot is not enabled")
}
menus := getMenu()
TGBot.SetMyCommands(menus, nil)
TGDispatcher.RemoveGroup(0)
initCommand(TGDispatcher, menus)
return nil
}
func StopTelegramBot() {
if TGEnabled {
TGupdater.Stop()
TGupdater = nil
TGEnabled = false
}
}
func setDispatcher() *ext.Dispatcher {
menus := getMenu()
TGBot.SetMyCommands(menus, nil)
// Create dispatcher.
dispatcher := ext.NewDispatcher(&ext.DispatcherOpts{
// If an error is returned by a handler, log it and continue going.
Error: func(b *gotgbot.Bot, ctx *ext.Context, err error) ext.DispatcherAction {
common.SysLog("telegram an error occurred while handling update: " + err.Error())
return ext.DispatcherActionNoop
},
MaxRoutines: ext.DefaultMaxRoutines,
})
initCommand(dispatcher, menus)
return dispatcher
}
func initCommand(dispatcher *ext.Dispatcher, menu []gotgbot.BotCommand) {
dispatcher.AddHandler(handlers.NewCallback(callbackquery.Prefix("p:"), paginationHandler))
for _, command := range menu {
switch command.Command {
case "bind":
dispatcher.AddHandler(commandBindInit())
case "unbind":
dispatcher.AddHandler(handlers.NewCommand("unbind", commandUnbindStart))
case "balance":
dispatcher.AddHandler(handlers.NewCommand("balance", commandBalanceStart))
case "recharge":
dispatcher.AddHandler(commandRechargeInit())
case "apikey":
dispatcher.AddHandler(handlers.NewCommand("apikey", commandApikeyStart))
case "aff":
dispatcher.AddHandler(handlers.NewCommand("aff", commandAffStart))
default:
dispatcher.AddHandler(handlers.NewCommand(command.Command, commandCustom))
}
}
}
func getMenu() []gotgbot.BotCommand {
defaultMenu := GetDefaultMenu()
customMenu, err := model.GetTelegramMenus()
if err != nil {
common.SysLog("Failed to get custom menu, error: " + err.Error())
}
if len(customMenu) > 0 {
// 追加自定义菜单
for _, menu := range customMenu {
defaultMenu = append(defaultMenu, gotgbot.BotCommand{Command: menu.Command, Description: menu.Description})
}
}
return defaultMenu
}
// 菜单 1. 绑定 2. 解绑 3. 查询余额 4. 充值 5. 获取API_KEY
func GetDefaultMenu() []gotgbot.BotCommand {
return []gotgbot.BotCommand{
{Command: "bind", Description: "绑定账号"},
{Command: "unbind", Description: "解绑账号"},
{Command: "balance", Description: "查询余额"},
{Command: "recharge", Description: "充值"},
{Command: "apikey", Description: "获取API_KEY"},
{Command: "aff", Description: "获取邀请链接"},
}
}
func noCommands(msg *gotgbot.Message) bool {
return message.Text(msg) && !message.Command(msg)
}
func getTGUserId(b *gotgbot.Bot, ctx *ext.Context) int64 {
if ctx.EffectiveSender.User == nil {
ctx.EffectiveMessage.Reply(b, "无法使用命令", nil)
return 0
}
return ctx.EffectiveSender.User.Id
}
func getBindUser(b *gotgbot.Bot, ctx *ext.Context) *model.User {
tgUserId := getTGUserId(b, ctx)
if tgUserId == 0 {
return nil
}
user, err := model.GetUserByTelegramId(tgUserId)
if err != nil {
ctx.EffectiveMessage.Reply(b, "您的账户未绑定", nil)
return nil
}
return user
}

View File

@ -0,0 +1,46 @@
package telegram
import (
"fmt"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/conversation"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/callbackquery"
)
func cancelConversationInlineKeyboard() gotgbot.InlineKeyboardMarkup {
bt := gotgbot.InlineKeyboardMarkup{
InlineKeyboard: [][]gotgbot.InlineKeyboardButton{{
{Text: "取消", CallbackData: "cancel"},
}},
}
return bt
}
func cancelConversationOpts() *handlers.ConversationOpts {
return &handlers.ConversationOpts{
Exits: []ext.Handler{handlers.NewCallback(callbackquery.Equal("cancel"), cancelConversation)},
StateStorage: conversation.NewInMemoryStorage(conversation.KeyStrategySenderAndChat),
AllowReEntry: true,
}
}
func cancelConversation(b *gotgbot.Bot, ctx *ext.Context) error {
cb := ctx.Update.CallbackQuery
_, err := cb.Answer(b, &gotgbot.AnswerCallbackQueryOpts{
Text: "已取消!",
})
if err != nil {
return fmt.Errorf("failed to answer start callback query: %w", err)
}
_, err = cb.Message.Delete(b, nil)
if err != nil {
return fmt.Errorf("failed to send cancel message: %w", err)
}
return handlers.EndConversation()
}

View File

@ -0,0 +1,85 @@
package telegram
import (
"fmt"
"strconv"
"strings"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
type paginationParams struct {
key string
page int
total int
}
func paginationHandler(b *gotgbot.Bot, ctx *ext.Context) error {
user := getBindUser(b, ctx)
if user == nil {
return nil
}
cb := ctx.Update.CallbackQuery
parts := strings.Split(strings.TrimPrefix(ctx.CallbackQuery.Data, "p:"), ",")
page, err := strconv.Atoi(parts[1])
if err != nil {
cb.Answer(b, &gotgbot.AnswerCallbackQueryOpts{
Text: "参数错误!",
})
return nil
}
switch parts[0] {
case "apikey":
message, pageParams := getApikeyList(user.Id, page)
if pageParams == nil {
cb.Answer(b, &gotgbot.AnswerCallbackQueryOpts{
Text: message,
})
return nil
}
_, _, err := cb.Message.EditText(b, message, &gotgbot.EditMessageTextOpts{
ParseMode: "MarkdownV2",
ReplyMarkup: getPaginationInlineKeyboard(pageParams.key, pageParams.page, pageParams.total),
})
if err != nil {
return fmt.Errorf("failed to send APIKEY message: %w", err)
}
default:
cb.Answer(b, &gotgbot.AnswerCallbackQueryOpts{
Text: "未知的类型!",
})
}
return nil
}
func getPaginationInlineKeyboard(key string, page int, total int) gotgbot.InlineKeyboardMarkup {
var bt gotgbot.InlineKeyboardMarkup
var buttons []gotgbot.InlineKeyboardButton
if page > 1 {
buttons = append(buttons, gotgbot.InlineKeyboardButton{Text: fmt.Sprintf("上一页(%d/%d)", page-1, total), CallbackData: fmt.Sprintf("p:%s,%d", key, page-1)})
}
if page < total {
buttons = append(buttons, gotgbot.InlineKeyboardButton{Text: fmt.Sprintf("下一页(%d/%d)", page+1, total), CallbackData: fmt.Sprintf("p:%s,%d", key, page+1)})
}
bt.InlineKeyboard = append(bt.InlineKeyboard, buttons)
return bt
}
func getPageParams(key string, page, size, total_count int) *paginationParams {
// 根据总数计算总页数
total := total_count / size
if total_count%size > 0 {
total++
}
return &paginationParams{
page: page,
total: total,
key: key,
}
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"one-api/common"
"one-api/common/telegram"
"one-api/model"
"strings"
@ -12,6 +13,11 @@ import (
)
func GetStatus(c *gin.Context) {
telegram_bot := ""
if telegram.TGEnabled {
telegram_bot = telegram.TGBot.User.Username
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -33,6 +39,7 @@ func GetStatus(c *gin.Context) {
"chat_link": common.ChatLink,
"quota_per_unit": common.QuotaPerUnit,
"display_in_currency": common.DisplayInCurrencyEnabled,
"telegram_bot": telegram_bot,
},
})
}

131
controller/telegram.go Normal file
View File

@ -0,0 +1,131 @@
package controller
import (
"errors"
"net/http"
"one-api/common"
"one-api/common/telegram"
"one-api/model"
"strconv"
"github.com/gin-gonic/gin"
)
func TelegramBotWebHook(c *gin.Context) {
handlerFunc := telegram.TGupdater.GetHandlerFunc("/")
handlerFunc(c.Writer, c.Request)
}
func GetTelegramMenuList(c *gin.Context) {
var params model.GenericParams
if err := c.ShouldBindQuery(&params); err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
list, err := model.GetTelegramMenusList(&params)
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": list,
})
}
func GetTelegramMenu(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
menu, err := model.GetTelegramMenuById(id)
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": menu,
})
}
func AddOrUpdateTelegramMenu(c *gin.Context) {
menu := model.TelegramMenu{}
err := c.ShouldBindJSON(&menu)
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
defaultMenu := telegram.GetDefaultMenu()
// 遍历, 禁止有相同的command
for _, v := range defaultMenu {
if v.Command == menu.Command {
common.APIRespondWithError(c, http.StatusOK, errors.New("command已存在"))
return
}
}
if model.IsTelegramCommandAlreadyTaken(menu.Command, menu.Id) {
common.APIRespondWithError(c, http.StatusOK, errors.New("command已存在"))
return
}
message := "添加成功"
if menu.Id == 0 {
err = menu.Insert()
} else {
err = menu.Update()
message = "修改成功"
}
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": message,
})
}
func DeleteTelegramMenu(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
menu := model.TelegramMenu{Id: id}
err := menu.Delete()
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "删除成功",
})
}
func GetTelegramBotStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"status": telegram.TGEnabled,
"is_webhook": telegram.TGWebHookSecret != "",
},
})
}
func ReloadTelegramBot(c *gin.Context) {
err := telegram.ReloadMenuAndCommands()
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "重载成功",
})
}

1
go.mod
View File

@ -25,6 +25,7 @@ require (
)
require (
github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.24 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect

2
go.sum
View File

@ -1,3 +1,5 @@
github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.24 h1:1T7RcpzlldaJ3qpZi0lNg/lBsfPCK+8n8Wc+R8EhAkU=
github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.24/go.mod h1:kL1v4iIjlalwm3gCYGvF4NLa3hs+aKEfRkNJvj4aoDU=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=

View File

@ -4,6 +4,7 @@ import (
"embed"
"fmt"
"one-api/common"
"one-api/common/telegram"
"one-api/controller"
"one-api/middleware"
"one-api/model"
@ -84,6 +85,8 @@ func main() {
model.InitBatchUpdater()
}
common.InitTokenEncoders()
// Initialize Telegram bot
telegram.InitTelegramBot()
// Initialize HTTP server
server := gin.New()

22
middleware/telegram.go Normal file
View File

@ -0,0 +1,22 @@
package middleware
import (
"one-api/common/telegram"
"os"
"github.com/gin-gonic/gin"
)
func Telegram() func(c *gin.Context) {
return func(c *gin.Context) {
token := c.Param("token")
if !telegram.TGEnabled || telegram.TGWebHookSecret == "" || token == "" || token != os.Getenv("TG_BOT_API_KEY") {
c.String(404, "Page not found")
c.Abort()
return
}
c.Next()
}
}

View File

@ -40,7 +40,7 @@ var allowedChannelOrderFields = map[string]bool{
"priority": true,
}
func GetChannelsList(params *GenericParams) (*DataResult, error) {
func GetChannelsList(params *GenericParams) (*DataResult[Channel], error) {
var channels []*Channel
db := DB.Omit("key")
@ -52,7 +52,7 @@ func GetChannelsList(params *GenericParams) (*DataResult, error) {
db = db.Where("id = ? or name LIKE ? or "+keyCol+" = ?", common.String2Int(params.Keyword), params.Keyword+"%", params.Keyword)
}
return PaginateAndOrder(db, &params.PaginationParams, &channels, allowedChannelOrderFields)
return PaginateAndOrder[Channel](db, &params.PaginationParams, &channels, allowedChannelOrderFields)
}
func GetAllChannels() ([]*Channel, error) {

View File

@ -8,6 +8,10 @@ import (
"gorm.io/gorm"
)
type modelable interface {
any
}
type GenericParams struct {
PaginationParams
Keyword string `form:"keyword"`
@ -19,14 +23,14 @@ type PaginationParams struct {
Order string `form:"order"`
}
type DataResult struct {
Data interface{} `json:"data"`
Page int `json:"page"`
Size int `json:"size"`
TotalCount int64 `json:"total_count"`
type DataResult[T modelable] struct {
Data *[]*T `json:"data"`
Page int `json:"page"`
Size int `json:"size"`
TotalCount int64 `json:"total_count"`
}
func PaginateAndOrder(db *gorm.DB, params *PaginationParams, result interface{}, allowedOrderFields map[string]bool) (*DataResult, error) {
func PaginateAndOrder[T modelable](db *gorm.DB, params *PaginationParams, result *[]*T, allowedOrderFields map[string]bool) (*DataResult[T], error) {
// 获取总数
var totalCount int64
err := db.Model(result).Count(&totalCount).Error
@ -34,8 +38,6 @@ func PaginateAndOrder(db *gorm.DB, params *PaginationParams, result interface{},
return nil, err
}
fmt.Println("totalCount", totalCount)
// 分页
if params.Page < 1 {
params.Page = 1
@ -80,7 +82,7 @@ func PaginateAndOrder(db *gorm.DB, params *PaginationParams, result interface{},
}
// 返回结果
return &DataResult{
return &DataResult[T]{
Data: result,
Page: params.Page,
Size: params.Size,

View File

@ -94,7 +94,7 @@ var allowedLogsOrderFields = map[string]bool{
"type": true,
}
func GetLogsList(params *LogsListParams) (*DataResult, error) {
func GetLogsList(params *LogsListParams) (*DataResult[Log], error) {
var tx *gorm.DB
var logs []*Log
@ -122,10 +122,10 @@ func GetLogsList(params *LogsListParams) (*DataResult, error) {
tx = tx.Where("channel_id = ?", params.Channel)
}
return PaginateAndOrder(tx, &params.PaginationParams, &logs, allowedLogsOrderFields)
return PaginateAndOrder[Log](tx, &params.PaginationParams, &logs, allowedLogsOrderFields)
}
func GetUserLogsList(userId int, params *LogsListParams) (*DataResult, error) {
func GetUserLogsList(userId int, params *LogsListParams) (*DataResult[Log], error) {
var logs []*Log
tx := DB.Where("user_id = ?", userId).Omit("id")
@ -146,7 +146,7 @@ func GetUserLogsList(userId int, params *LogsListParams) (*DataResult, error) {
tx = tx.Where("created_at <= ?", params.EndTimestamp)
}
return PaginateAndOrder(tx, &params.PaginationParams, &logs, allowedLogsOrderFields)
return PaginateAndOrder[Log](tx, &params.PaginationParams, &logs, allowedLogsOrderFields)
}
func SearchAllLogs(keyword string) (logs []*Log, err error) {

View File

@ -2,14 +2,15 @@ package model
import (
"fmt"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"one-api/common"
"os"
"strings"
"time"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var DB *gorm.DB
@ -113,6 +114,10 @@ func InitDB() (err error) {
if err != nil {
return err
}
err = db.AutoMigrate(&TelegramMenu{})
if err != nil {
return err
}
common.SysLog("database migrated")
err = createRootAccountIfNeed()
return err

View File

@ -29,14 +29,14 @@ var allowedRedemptionslOrderFields = map[string]bool{
"redeemed_time": true,
}
func GetRedemptionsList(params *GenericParams) (*DataResult, error) {
func GetRedemptionsList(params *GenericParams) (*DataResult[Redemption], error) {
var redemptions []*Redemption
db := DB
if params.Keyword != "" {
db = db.Where("id = ? or name LIKE ?", common.String2Int(params.Keyword), params.Keyword+"%")
}
return PaginateAndOrder(db, &params.PaginationParams, &redemptions, allowedRedemptionslOrderFields)
return PaginateAndOrder[Redemption](db, &params.PaginationParams, &redemptions, allowedRedemptionslOrderFields)
}
func GetRedemptionById(id int) (*Redemption, error) {

73
model/telegram_menu.go Normal file
View File

@ -0,0 +1,73 @@
package model
import (
"errors"
"one-api/common"
)
type TelegramMenu struct {
Id int `json:"id"`
Command string `json:"command" gorm:"type:varchar(32);uniqueIndex"`
Description string `json:"description" gorm:"type:varchar(255);default:''"`
ParseMode string `json:"parse_mode" gorm:"type:varchar(255);default:'MarkdownV2'"`
ReplyMessage string `json:"reply_message"`
}
var allowedTelegramMenusOrderFields = map[string]bool{
"id": true,
"command": true,
}
func GetTelegramMenusList(params *GenericParams) (*DataResult[TelegramMenu], error) {
var menus []*TelegramMenu
db := DB
if params.Keyword != "" {
db = db.Where("id = ? or command LIKE ?", common.String2Int(params.Keyword), params.Keyword+"%")
}
return PaginateAndOrder[TelegramMenu](db, &params.PaginationParams, &menus, allowedTelegramMenusOrderFields)
}
// 查询菜单列表 只查询command和description
func GetTelegramMenus() ([]*TelegramMenu, error) {
var menus []*TelegramMenu
err := DB.Select("command, description").Find(&menus).Error
return menus, err
}
// 根据command查询菜单
func GetTelegramMenuByCommand(command string) (*TelegramMenu, error) {
menu := &TelegramMenu{}
err := DB.Where("command = ?", command).First(menu).Error
return menu, err
}
func GetTelegramMenuById(id int) (*TelegramMenu, error) {
if id == 0 {
return nil, errors.New("id 为空!")
}
telegramMenu := TelegramMenu{Id: id}
var err error = nil
err = DB.First(&telegramMenu, "id = ?", id).Error
return &telegramMenu, err
}
func IsTelegramCommandAlreadyTaken(command string, id int) bool {
query := DB.Where("command = ?", command)
if id != 0 {
query = query.Not("id", id)
}
return query.Find(&TelegramMenu{}).RowsAffected == 1
}
func (menu *TelegramMenu) Insert() error {
return DB.Create(menu).Error
}
func (menu *TelegramMenu) Update() error {
return DB.Model(menu).Updates(menu).Error
}
func (menu *TelegramMenu) Delete() error {
return DB.Delete(menu).Error
}

View File

@ -32,7 +32,7 @@ var allowedTokenOrderFields = map[string]bool{
"used_quota": true,
}
func GetUserTokensList(userId int, params *GenericParams) (*DataResult, error) {
func GetUserTokensList(userId int, params *GenericParams) (*DataResult[Token], error) {
var tokens []*Token
db := DB.Where("user_id = ?", userId)
@ -40,7 +40,13 @@ func GetUserTokensList(userId int, params *GenericParams) (*DataResult, error) {
db = db.Where("name LIKE ?", params.Keyword+"%")
}
return PaginateAndOrder(db, &params.PaginationParams, &tokens, allowedTokenOrderFields)
return PaginateAndOrder[Token](db, &params.PaginationParams, &tokens, allowedTokenOrderFields)
}
// 获取状态为可用的令牌
func GetUserEnabledTokens(userId int) (tokens []*Token, err error) {
err = DB.Where("user_id = ? and status = ?", userId, common.TokenStatusEnabled).Find(&tokens).Error
return tokens, err
}
func ValidateUserToken(key string) (token *Token, err error) {

View File

@ -21,6 +21,7 @@ type User struct {
Email string `json:"email" gorm:"index" validate:"max=50"`
GitHubId string `json:"github_id" gorm:"column:github_id;index"`
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
TelegramId int64 `json:"telegram_id" gorm:"bigint,column:telegram_id;default:0;"`
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
AccessToken string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
Quota int `json:"quota" gorm:"type:int;default:0"`
@ -32,6 +33,8 @@ type User struct {
CreatedTime int64 `json:"created_time" gorm:"bigint"`
}
type UserUpdates func(*User)
func GetMaxUserId() int {
var user User
DB.Last(&user)
@ -46,14 +49,14 @@ var allowedUserOrderFields = map[string]bool{
"created_time": true,
}
func GetUsersList(params *GenericParams) (*DataResult, error) {
func GetUsersList(params *GenericParams) (*DataResult[User], error) {
var users []*User
db := DB.Omit("password")
if params.Keyword != "" {
db = db.Where("id = ? or username LIKE ? or email LIKE ? or display_name LIKE ?", common.String2Int(params.Keyword), params.Keyword+"%", params.Keyword+"%", params.Keyword+"%")
}
return PaginateAndOrder(db, &params.PaginationParams, &users, allowedUserOrderFields)
return PaginateAndOrder[User](db, &params.PaginationParams, &users, allowedUserOrderFields)
}
func GetUserById(id int, selectAll bool) (*User, error) {
@ -70,6 +73,17 @@ func GetUserById(id int, selectAll bool) (*User, error) {
return &user, err
}
func GetUserByTelegramId(telegramId int64) (*User, error) {
if telegramId == 0 {
return nil, errors.New("telegramId 为空!")
}
var user User
err := DB.First(&user, "telegram_id = ?", telegramId).Error
return &user, err
}
func GetUserIdByAffCode(affCode string) (int, error) {
if affCode == "" {
return 0, errors.New("affCode 为空!")
@ -131,6 +145,10 @@ func (user *User) Update(updatePassword bool) error {
return err
}
func UpdateUser(id int, fields map[string]interface{}) error {
return DB.Model(&User{}).Where("id = ?", id).Updates(fields).Error
}
func (user *User) Delete() error {
if user.Id == 0 {
return errors.New("id 为空!")
@ -216,6 +234,10 @@ func IsGitHubIdAlreadyTaken(githubId string) bool {
return DB.Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1
}
func IsTelegramIdAlreadyTaken(telegramId int64) bool {
return DB.Where("telegram_id = ?", telegramId).Find(&User{}).RowsAffected == 1
}
func IsUsernameAlreadyTaken(username string) bool {
return DB.Where("username = ?", username).Find(&User{}).RowsAffected == 1
}

View File

@ -11,6 +11,7 @@ import (
func SetApiRouter(router *gin.Engine) {
apiRouter := router.Group("/api")
apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
apiRouter.POST("/telegram/:token", middleware.Telegram(), controller.TelegramBotWebHook)
apiRouter.Use(middleware.GlobalAPIRateLimit())
{
apiRouter.GET("/status", controller.GetStatus)
@ -61,6 +62,12 @@ func SetApiRouter(router *gin.Engine) {
{
optionRoute.GET("/", controller.GetOptions)
optionRoute.PUT("/", controller.UpdateOption)
optionRoute.GET("/telegram", controller.GetTelegramMenuList)
optionRoute.POST("/telegram", controller.AddOrUpdateTelegramMenu)
optionRoute.GET("/telegram/status", controller.GetTelegramBotStatus)
optionRoute.PUT("/telegram/reload", controller.ReloadTelegramBot)
optionRoute.GET("/telegram/:id", controller.GetTelegramMenu)
optionRoute.DELETE("/telegram/:id", controller.DeleteTelegramMenu)
}
channelRoute := apiRouter.Group("/channel")
channelRoute.Use(middleware.AdminAuth())
@ -120,4 +127,5 @@ func SetApiRouter(router *gin.Engine) {
analyticsRoute.GET("/redemption_period", controller.GetRedemptionStatisticsByPeriod)
}
}
}

View File

@ -9,7 +9,8 @@ import {
IconGardenCart,
IconUser,
IconUserScan,
IconActivity
IconActivity,
IconBrandTelegram
} from '@tabler/icons-react';
// constant
@ -23,7 +24,8 @@ const icons = {
IconGardenCart,
IconUser,
IconUserScan,
IconActivity
IconActivity,
IconBrandTelegram
};
// ==============================|| DASHBOARD MENU ITEMS ||============================== //
@ -118,6 +120,15 @@ const panel = {
icon: icons.IconAdjustments,
breadcrumbs: false,
isAdmin: true
},
{
id: 'telegram',
title: 'Telegram Bot',
type: 'item',
url: '/panel/telegram',
icon: icons.IconBrandTelegram,
breadcrumbs: false,
isAdmin: true
}
]
};

View File

@ -14,6 +14,7 @@ const User = Loadable(lazy(() => import('views/User')));
const Profile = Loadable(lazy(() => import('views/Profile')));
const NotFoundView = Loadable(lazy(() => import('views/Error')));
const Analytics = Loadable(lazy(() => import('views/Analytics')));
const Telegram = Loadable(lazy(() => import('views/Telegram')));
// dashboard routing
const Dashboard = Loadable(lazy(() => import('views/Dashboard')));
@ -71,6 +72,10 @@ const MainRoutes = {
{
path: '404',
element: <NotFoundView />
},
{
path: 'telegram',
element: <Telegram />
}
]
};

View File

@ -13,6 +13,7 @@ const ResetPassword = Loadable(lazy(() => import('views/Authentication/Auth/Rese
const Home = Loadable(lazy(() => import('views/Home')));
const About = Loadable(lazy(() => import('views/About')));
const NotFoundView = Loadable(lazy(() => import('views/Error')));
const Jump = Loadable(lazy(() => import('views/Jump')));
// ==============================|| AUTHENTICATION ROUTING ||============================== //
@ -51,6 +52,10 @@ const OtherRoutes = {
{
path: '/404',
element: <NotFoundView />
},
{
path: '/jump',
element: <Jump />
}
]
};

View File

@ -0,0 +1,16 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export default function Jump() {
const location = useLocation();
useEffect(() => {
const params = new URLSearchParams(location.search);
const jump = params.get('url');
if (jump) {
window.location.href = jump;
}
}, [location]);
return <div>正在跳转中...</div>;
}

View File

@ -12,11 +12,13 @@ import {
DialogTitle,
DialogContent,
DialogActions,
Divider
Divider,
Chip,
Typography
} from '@mui/material';
import Grid from '@mui/material/Unstable_Grid2';
import SubCard from 'ui-component/cards/SubCard';
import { IconBrandWechat, IconBrandGithub, IconMail } from '@tabler/icons-react';
import { IconBrandWechat, IconBrandGithub, IconMail, IconBrandTelegram } from '@tabler/icons-react';
import Label from 'ui-component/Label';
import { API } from 'utils/api';
import { showError, showSuccess, onGitHubOAuthClicked, copy } from 'utils/common';
@ -141,6 +143,9 @@ export default function Profile() {
<Label variant="ghost" color={inputs.email ? 'primary' : 'default'}>
<IconMail /> {inputs.email || '未绑定'}
</Label>
<Label variant="ghost" color={inputs.telegram_id ? 'primary' : 'default'}>
<IconBrandTelegram /> {inputs.telegram_id || '未绑定'}
</Label>
</Stack>
<SubCard title="个人信息">
<Grid container spacing={2}>
@ -209,6 +214,7 @@ export default function Profile() {
</Button>
</Grid>
)}
<Grid xs={12} md={4}>
<Button
variant="contained"
@ -229,6 +235,35 @@ export default function Profile() {
<></>
)}
</Grid>
{status.telegram_bot && ( //&& !inputs.telegram_id
<Grid xs={12} md={12}>
<Stack spacing={2}>
<Divider />
<Alert severity="info">
<Typography variant="h3">Telegram 机器人</Typography>
<br />
<Typography variant="body1">
1. 点击下方按钮将会在 Telegram 中打开 机器人点击 /start 开始
<br />
<Chip
icon={<IconBrandTelegram />}
label={'@' + status.telegram_bot}
color="primary"
variant="outlined"
size="small"
onClick={() => window.open('https://t.me/' + status.telegram_bot, '_blank')}
/>
<br />
<br />
2. 向机器人发送/bind命令后输入下方的访问令牌即可绑定(如果没有生成请点击下方按钮生成)
</Typography>
</Alert>
{/* <Typography variant=""> */}
</Stack>
</Grid>
)}
</Grid>
</SubCard>
<SubCard title="其他">

View File

@ -0,0 +1,225 @@
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { Formik } from 'formik';
import { useTheme } from '@mui/material/styles';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Divider,
FormControl,
InputLabel,
OutlinedInput,
FormHelperText,
Select,
MenuItem,
TextField
} from '@mui/material';
import { showSuccess, showError } from 'utils/common';
import { API } from 'utils/api';
const validationSchema = Yup.object().shape({
is_edit: Yup.boolean(),
command: Yup.string().required('命令 不能为空'),
description: Yup.string().required('说明 不能为空'),
parse_mode: Yup.string().required('消息类型 不能为空'),
reply_message: Yup.string().required('消息内容 不能为空')
});
const originInputs = {
command: '',
description: '',
parse_mode: 'MarkdownV2',
reply_message: ''
};
const EditModal = ({ open, actionId, onCancel, onOk }) => {
const theme = useTheme();
const [inputs, setInputs] = useState(originInputs);
const submit = async (values, { setErrors, setStatus, setSubmitting }) => {
setSubmitting(true);
let res;
try {
if (values.is_edit) {
res = await API.post(`/api/option/telegram/`, { ...values, id: parseInt(actionId) });
} else {
res = await API.post(`/api/option/telegram/`, values);
}
const { success, message } = res.data;
if (success) {
if (values.is_edit) {
showSuccess('菜单更新成功!');
} else {
showSuccess('菜单创建成功!');
}
setSubmitting(false);
setStatus({ success: true });
onOk(true);
} else {
showError(message);
setErrors({ submit: message });
}
} catch (error) {
return;
}
};
const load = async () => {
try {
let res = await API.get(`/api/option/telegram/${actionId}`);
const { success, message, data } = res.data;
if (success) {
data.is_edit = true;
setInputs(data);
} else {
showError(message);
}
} catch (error) {
return;
}
};
useEffect(() => {
if (actionId) {
load().then();
} else {
setInputs(originInputs);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionId]);
return (
<Dialog open={open} onClose={onCancel} fullWidth maxWidth={'md'}>
<DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>
{actionId ? '编辑菜单' : '新建菜单'}
</DialogTitle>
<Divider />
<DialogContent>
<Formik initialValues={inputs} enableReinitialize validationSchema={validationSchema} onSubmit={submit}>
{({ errors, handleBlur, handleChange, handleSubmit, touched, values, isSubmitting }) => (
<form noValidate onSubmit={handleSubmit}>
<FormControl fullWidth error={Boolean(touched.command && errors.command)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-command-label">命令</InputLabel>
<OutlinedInput
id="channel-command-label"
label="命令"
type="text"
value={values.command}
name="command"
onBlur={handleBlur}
onChange={handleChange}
inputProps={{ autoComplete: 'command' }}
aria-describedby="helper-text-channel-command-label"
/>
{touched.command && errors.command && (
<FormHelperText error id="helper-tex-channel-command-label">
{errors.command}
</FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.description && errors.description)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-description-label">说明</InputLabel>
<OutlinedInput
id="channel-description-label"
label="说明"
type="text"
value={values.description}
name="description"
onBlur={handleBlur}
onChange={handleChange}
inputProps={{ autoComplete: 'description' }}
aria-describedby="helper-text-channel-description-label"
/>
{touched.description && errors.description && (
<FormHelperText error id="helper-tex-channel-description-label">
{errors.description}
</FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.parse_mode && errors.parse_mode)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-parse_mode-label">消息类型</InputLabel>
<Select
id="channel-parse_mode-label"
label="消息类型"
value={values.parse_mode}
name="parse_mode"
onBlur={handleBlur}
onChange={handleChange}
MenuProps={{
PaperProps: {
style: {
maxHeight: 200
}
}
}}
>
<MenuItem key="MarkdownV2" value="MarkdownV2">
{' '}
MarkdownV2{' '}
</MenuItem>
<MenuItem key="Markdown" value="Markdown">
{' '}
Markdown{' '}
</MenuItem>
<MenuItem key="html" value="html">
{' '}
html{' '}
</MenuItem>
</Select>
{touched.parse_mode && errors.parse_mode && (
<FormHelperText error id="helper-tex-channel-parse_mode-label">
{errors.parse_mode}
</FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.reply_message && errors.reply_message)} sx={{ ...theme.typography.otherInput }}>
<TextField
multiline
id="channel-reply_message-label"
label="消息内容"
value={values.reply_message}
name="reply_message"
onBlur={handleBlur}
onChange={handleChange}
aria-describedby="helper-text-channel-reply_message-label"
minRows={5}
placeholder="消息内容"
/>
{touched.reply_message && errors.reply_message && (
<FormHelperText error id="helper-tex-channel-reply_message-label">
{errors.reply_message}
</FormHelperText>
)}
</FormControl>
<DialogActions>
<Button onClick={onCancel}>取消</Button>
<Button disableElevation disabled={isSubmitting} type="submit" variant="contained" color="primary">
提交
</Button>
</DialogActions>
</form>
)}
</Formik>
</DialogContent>
</Dialog>
);
};
export default EditModal;
EditModal.propTypes = {
open: PropTypes.bool,
actionId: PropTypes.number,
onCancel: PropTypes.func,
onOk: PropTypes.func
};

View File

@ -0,0 +1,114 @@
import PropTypes from 'prop-types';
import { useState } from 'react';
import {
Popover,
TableRow,
MenuItem,
TableCell,
IconButton,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Button,
Stack
} from '@mui/material';
import { IconDotsVertical, IconEdit, IconTrash } from '@tabler/icons-react';
export default function TelegramTableRow({ item, manageAction, handleOpenModal, setModalId }) {
const [open, setOpen] = useState(null);
const [openDelete, setOpenDelete] = useState(false);
const handleDeleteOpen = () => {
handleCloseMenu();
setOpenDelete(true);
};
const handleDeleteClose = () => {
setOpenDelete(false);
};
const handleOpenMenu = (event) => {
setOpen(event.currentTarget);
};
const handleCloseMenu = () => {
setOpen(null);
};
const handleDelete = async () => {
handleCloseMenu();
await manageAction(item.id, 'delete');
};
return (
<>
<TableRow tabIndex={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>{item.command}</TableCell>
<TableCell>{item.description}</TableCell>
<TableCell>{item.parse_mode}</TableCell>
<TableCell>{item.reply_message}</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
<IconButton onClick={handleOpenMenu} sx={{ color: 'rgb(99, 115, 129)' }}>
<IconDotsVertical />
</IconButton>
</Stack>
</TableCell>
</TableRow>
<Popover
open={!!open}
anchorEl={open}
onClose={handleCloseMenu}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{
sx: { width: 140 }
}}
>
<MenuItem
onClick={() => {
handleCloseMenu();
handleOpenModal();
setModalId(item.id);
}}
>
<IconEdit style={{ marginRight: '16px' }} />
编辑
</MenuItem>
<MenuItem onClick={handleDeleteOpen} sx={{ color: 'error.main' }}>
<IconTrash style={{ marginRight: '16px' }} />
删除
</MenuItem>
</Popover>
<Dialog open={openDelete} onClose={handleDeleteClose}>
<DialogTitle>删除菜单</DialogTitle>
<DialogContent>
<DialogContentText>是否删除菜单 {item.name}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteClose}>关闭</Button>
<Button onClick={handleDelete} sx={{ color: 'error.main' }} autoFocus>
删除
</Button>
</DialogActions>
</Dialog>
</>
);
}
TelegramTableRow.propTypes = {
item: PropTypes.object,
manageAction: PropTypes.func,
handleOpenModal: PropTypes.func,
setModalId: PropTypes.func
};

View File

@ -0,0 +1,270 @@
import { useState, useEffect } from 'react';
import { showError, showSuccess } from 'utils/common';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableContainer from '@mui/material/TableContainer';
import PerfectScrollbar from 'react-perfect-scrollbar';
import TablePagination from '@mui/material/TablePagination';
import LinearProgress from '@mui/material/LinearProgress';
import ButtonGroup from '@mui/material/ButtonGroup';
import Toolbar from '@mui/material/Toolbar';
import { Button, Card, Box, Stack, Container, Typography, Chip, Alert } from '@mui/material';
import TelegramTableRow from './component/TableRow';
import KeywordTableHead from 'ui-component/TableHead';
import TableToolBar from 'ui-component/TableToolBar';
import { API } from 'utils/api';
import { ITEMS_PER_PAGE } from 'constants';
import { IconRefresh, IconPlus } from '@tabler/icons-react';
import EditeModal from './component/EditModal';
import { IconBrandTelegram, IconReload } from '@tabler/icons-react';
// ----------------------------------------------------------------------
export default function Telegram() {
const [page, setPage] = useState(0);
const [order, setOrder] = useState('desc');
const [orderBy, setOrderBy] = useState('id');
const [rowsPerPage, setRowsPerPage] = useState(ITEMS_PER_PAGE);
const [listCount, setListCount] = useState(0);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [telegramMenus, setTelegramMenus] = useState([]);
const [refreshFlag, setRefreshFlag] = useState(false);
let [status, setStatus] = useState(false);
let [isWebhook, setIsWebhook] = useState(false);
const [openModal, setOpenModal] = useState(false);
const [editTelegramMenusId, setEditTelegramMenusId] = useState(0);
const handleSort = (event, id) => {
const isAsc = orderBy === id && order === 'asc';
if (id !== '') {
setOrder(isAsc ? 'desc' : 'asc');
setOrderBy(id);
}
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setPage(0);
setRowsPerPage(parseInt(event.target.value, 10));
};
const searchMenus = async (event) => {
event.preventDefault();
const formData = new FormData(event.target);
setPage(0);
setSearchKeyword(formData.get('keyword'));
};
const fetchData = async (page, rowsPerPage, keyword, order, orderBy) => {
setSearching(true);
try {
if (orderBy) {
orderBy = order === 'desc' ? '-' + orderBy : orderBy;
}
const res = await API.get(`/api/option/telegram/`, {
params: {
page: page + 1,
size: rowsPerPage,
keyword: keyword,
order: orderBy
}
});
const { success, message, data } = res.data;
if (success) {
setListCount(data.total_count);
setTelegramMenus(data.data);
} else {
showError(message);
}
} catch (error) {
console.error(error);
}
setSearching(false);
};
const reload = async () => {
try {
const res = await API.put('/api/option/telegram/reload');
const { success, message } = res.data;
if (success) {
showSuccess('重载成功!');
} else {
showError(message);
}
} catch (error) {
return;
}
};
const getStatus = async () => {
try {
const res = await API.get('/api/option/telegram/status');
const { success, data } = res.data;
if (success) {
setStatus(data.status);
setIsWebhook(data.is_webhook);
}
} catch (error) {
return;
}
};
// 处理刷新
const handleRefresh = async () => {
setOrderBy('id');
setOrder('desc');
setRefreshFlag(!refreshFlag);
};
useEffect(() => {
fetchData(page, rowsPerPage, searchKeyword, order, orderBy);
}, [page, rowsPerPage, searchKeyword, order, orderBy, refreshFlag]);
useEffect(() => {
getStatus().then();
}, []);
const manageMenus = async (id, action) => {
const url = '/api/option/telegram/';
let res;
try {
switch (action) {
case 'delete':
res = await API.delete(url + id);
break;
}
const { success, message } = res.data;
if (success) {
showSuccess('操作成功完成!');
if (action === 'delete') {
await handleRefresh();
}
} else {
showError(message);
}
return res.data;
} catch (error) {
return;
}
};
const handleOpenModal = (id) => {
setEditTelegramMenusId(id);
setOpenModal(true);
};
const handleCloseModal = () => {
setOpenModal(false);
setEditTelegramMenusId(0);
};
const handleOkModal = (status) => {
if (status === true) {
handleCloseModal();
handleRefresh();
}
};
return (
<>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={5}>
<Typography variant="h4">Telegram Bot菜单</Typography>
<Button variant="contained" color="primary" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>
新建
</Button>
</Stack>
<Stack mb={5}>
<Alert severity="info">
添加修改菜单命令/说明后如果没有修改命令和说明可以不用重载需要重新载入菜单才能生效
如果未查看到新菜单请尝试杀后台后重新启动程序
</Alert>
</Stack>
<Stack direction="row" alignItems="center" justifyContent="flex-start" mb={2} spacing={2}>
<Chip
icon={<IconBrandTelegram />}
label={(status ? '在线' : '离线') + (isWebhook ? '(Webhook)' : '(Polling)')}
color={status ? 'primary' : 'error'}
variant="outlined"
size="small"
/>
<Button variant="contained" size="small" endIcon={<IconReload />} onClick={reload}>
重新载入菜单
</Button>
</Stack>
<Card>
<Box component="form" onSubmit={searchMenus} noValidate>
<TableToolBar placeholder={'搜索ID和命令...'} />
</Box>
<Toolbar
sx={{
textAlign: 'right',
height: 50,
display: 'flex',
justifyContent: 'space-between',
p: (theme) => theme.spacing(0, 1, 0, 3)
}}
>
<Container>
<ButtonGroup variant="outlined" aria-label="outlined small primary button group">
<Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>
刷新
</Button>
</ButtonGroup>
</Container>
</Toolbar>
{searching && <LinearProgress />}
<PerfectScrollbar component="div">
<TableContainer sx={{ overflow: 'unset' }}>
<Table sx={{ minWidth: 800 }}>
<KeywordTableHead
order={order}
orderBy={orderBy}
onRequestSort={handleSort}
headLabel={[
{ id: 'id', label: 'ID', disableSort: false },
{ id: 'command', label: '命令', disableSort: false },
{ id: 'description', label: '说明', disableSort: false },
{ id: 'parse_mode', label: '回复类型', disableSort: false },
{ id: 'reply_message', label: '回复内容', disableSort: false },
{ id: 'action', label: '操作', disableSort: true }
]}
/>
<TableBody>
{telegramMenus.map((row) => (
<TelegramTableRow
item={row}
manageAction={manageMenus}
key={row.id}
handleOpenModal={handleOpenModal}
setModalId={setEditTelegramMenusId}
/>
))}
</TableBody>
</Table>
</TableContainer>
</PerfectScrollbar>
<TablePagination
page={page}
component="div"
count={listCount}
rowsPerPage={rowsPerPage}
onPageChange={handleChangePage}
rowsPerPageOptions={[10, 25, 30]}
onRowsPerPageChange={handleChangeRowsPerPage}
showFirstButton
showLastButton
/>
</Card>
<EditeModal open={openModal} onCancel={handleCloseModal} onOk={handleOkModal} actionId={editTelegramMenusId} />
</>
);
}