From e90f4c99fcc69ce832f1f19aa0303acc7b584abb Mon Sep 17 00:00:00 2001 From: Buer <42402987+MartialBE@users.noreply.github.com> Date: Fri, 23 Feb 2024 18:24:25 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20telegram=20bot=20(#71?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/telegram/command_aff.go | 34 +++ common/telegram/command_apikey.go | 92 ++++++ common/telegram/command_balance.go | 28 ++ common/telegram/command_bind.go | 89 ++++++ common/telegram/command_custom.go | 54 ++++ common/telegram/command_recharge.go | 57 ++++ common/telegram/command_unbind.go | 29 ++ common/telegram/common.go | 220 ++++++++++++++ common/telegram/conversation.go | 46 +++ common/telegram/pagination.go | 85 ++++++ controller/misc.go | 7 + controller/telegram.go | 131 +++++++++ go.mod | 1 + go.sum | 2 + main.go | 3 + middleware/telegram.go | 22 ++ model/channel.go | 4 +- model/common.go | 20 +- model/log.go | 8 +- model/main.go | 13 +- model/redemption.go | 4 +- model/telegram_menu.go | 73 +++++ model/token.go | 10 +- model/user.go | 26 +- router/api-router.go | 8 + web/src/menu-items/panel.js | 15 +- web/src/routes/MainRoutes.js | 5 + web/src/routes/OtherRoutes.js | 5 + web/src/views/Jump/index.js | 16 ++ web/src/views/Profile/index.js | 39 ++- web/src/views/Telegram/component/EditModal.js | 225 +++++++++++++++ web/src/views/Telegram/component/TableRow.js | 114 ++++++++ web/src/views/Telegram/index.js | 270 ++++++++++++++++++ 33 files changed, 1726 insertions(+), 29 deletions(-) create mode 100644 common/telegram/command_aff.go create mode 100644 common/telegram/command_apikey.go create mode 100644 common/telegram/command_balance.go create mode 100644 common/telegram/command_bind.go create mode 100644 common/telegram/command_custom.go create mode 100644 common/telegram/command_recharge.go create mode 100644 common/telegram/command_unbind.go create mode 100644 common/telegram/common.go create mode 100644 common/telegram/conversation.go create mode 100644 common/telegram/pagination.go create mode 100644 controller/telegram.go create mode 100644 middleware/telegram.go create mode 100644 model/telegram_menu.go create mode 100644 web/src/views/Jump/index.js create mode 100644 web/src/views/Telegram/component/EditModal.js create mode 100644 web/src/views/Telegram/component/TableRow.js create mode 100644 web/src/views/Telegram/index.js diff --git a/common/telegram/command_aff.go b/common/telegram/command_aff.go new file mode 100644 index 00000000..fd15a2a6 --- /dev/null +++ b/common/telegram/command_aff.go @@ -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 +} diff --git a/common/telegram/command_apikey.go b/common/telegram/command_apikey.go new file mode 100644 index 00000000..c5d2e4da --- /dev/null +++ b/common/telegram/command_apikey.go @@ -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) +} diff --git a/common/telegram/command_balance.go b/common/telegram/command_balance.go new file mode 100644 index 00000000..481a4264 --- /dev/null +++ b/common/telegram/command_balance.go @@ -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("余额: $%s \n已用: $%s", quota, usedQuota), &gotgbot.SendMessageOpts{ + ParseMode: "html", + }) + + if err != nil { + return fmt.Errorf("failed to send balance message: %w", err) + } + + return err +} diff --git a/common/telegram/command_bind.go b/common/telegram/command_bind.go new file mode 100644 index 00000000..8519f064 --- /dev/null +++ b/common/telegram/command_bind.go @@ -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() +} diff --git a/common/telegram/command_custom.go b/common/telegram/command_custom.go new file mode 100644 index 00000000..6b7fb62e --- /dev/null +++ b/common/telegram/command_custom.go @@ -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 +} diff --git a/common/telegram/command_recharge.go b/common/telegram/command_recharge.go new file mode 100644 index 00000000..e26cb1f6 --- /dev/null +++ b/common/telegram/command_recharge.go @@ -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() +} diff --git a/common/telegram/command_unbind.go b/common/telegram/command_unbind.go new file mode 100644 index 00000000..da5fa5e3 --- /dev/null +++ b/common/telegram/command_unbind.go @@ -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 +} diff --git a/common/telegram/common.go b/common/telegram/common.go new file mode 100644 index 00000000..4d92cee4 --- /dev/null +++ b/common/telegram/common.go @@ -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 +} diff --git a/common/telegram/conversation.go b/common/telegram/conversation.go new file mode 100644 index 00000000..45016d30 --- /dev/null +++ b/common/telegram/conversation.go @@ -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() +} diff --git a/common/telegram/pagination.go b/common/telegram/pagination.go new file mode 100644 index 00000000..c38faff0 --- /dev/null +++ b/common/telegram/pagination.go @@ -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, + } +} diff --git a/controller/misc.go b/controller/misc.go index 4940bcf7..fd723a49 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -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, }, }) } diff --git a/controller/telegram.go b/controller/telegram.go new file mode 100644 index 00000000..500ebe5e --- /dev/null +++ b/controller/telegram.go @@ -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(¶ms); err != nil { + common.APIRespondWithError(c, http.StatusOK, err) + return + } + + list, err := model.GetTelegramMenusList(¶ms) + 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": "重载成功", + }) +} diff --git a/go.mod b/go.mod index fbb34d58..d8792ba3 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 2517022f..74696c0e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index 4c897d51..16ef5e48 100644 --- a/main.go +++ b/main.go @@ -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() diff --git a/middleware/telegram.go b/middleware/telegram.go new file mode 100644 index 00000000..d6d6559a --- /dev/null +++ b/middleware/telegram.go @@ -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() + } +} diff --git a/model/channel.go b/model/channel.go index 29c5b038..548a8c96 100644 --- a/model/channel.go +++ b/model/channel.go @@ -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, ¶ms.PaginationParams, &channels, allowedChannelOrderFields) + return PaginateAndOrder[Channel](db, ¶ms.PaginationParams, &channels, allowedChannelOrderFields) } func GetAllChannels() ([]*Channel, error) { diff --git a/model/common.go b/model/common.go index 37fcd739..84e1e422 100644 --- a/model/common.go +++ b/model/common.go @@ -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, diff --git a/model/log.go b/model/log.go index a4141952..a3cc46ad 100644 --- a/model/log.go +++ b/model/log.go @@ -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, ¶ms.PaginationParams, &logs, allowedLogsOrderFields) + return PaginateAndOrder[Log](tx, ¶ms.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, ¶ms.PaginationParams, &logs, allowedLogsOrderFields) + return PaginateAndOrder[Log](tx, ¶ms.PaginationParams, &logs, allowedLogsOrderFields) } func SearchAllLogs(keyword string) (logs []*Log, err error) { diff --git a/model/main.go b/model/main.go index bfd6888b..b4a90338 100644 --- a/model/main.go +++ b/model/main.go @@ -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 diff --git a/model/redemption.go b/model/redemption.go index a5389102..07df8d80 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -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, ¶ms.PaginationParams, &redemptions, allowedRedemptionslOrderFields) + return PaginateAndOrder[Redemption](db, ¶ms.PaginationParams, &redemptions, allowedRedemptionslOrderFields) } func GetRedemptionById(id int) (*Redemption, error) { diff --git a/model/telegram_menu.go b/model/telegram_menu.go new file mode 100644 index 00000000..451fda3e --- /dev/null +++ b/model/telegram_menu.go @@ -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, ¶ms.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 +} diff --git a/model/token.go b/model/token.go index 74eae34b..1e4e3123 100644 --- a/model/token.go +++ b/model/token.go @@ -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, ¶ms.PaginationParams, &tokens, allowedTokenOrderFields) + return PaginateAndOrder[Token](db, ¶ms.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) { diff --git a/model/user.go b/model/user.go index 50a6d5a8..3adb7b83 100644 --- a/model/user.go +++ b/model/user.go @@ -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, ¶ms.PaginationParams, &users, allowedUserOrderFields) + return PaginateAndOrder[User](db, ¶ms.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 } diff --git a/router/api-router.go b/router/api-router.go index f602a68a..f1bd65f2 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -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) } } + } diff --git a/web/src/menu-items/panel.js b/web/src/menu-items/panel.js index c4ee6b28..9bc945f7 100644 --- a/web/src/menu-items/panel.js +++ b/web/src/menu-items/panel.js @@ -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 } ] }; diff --git a/web/src/routes/MainRoutes.js b/web/src/routes/MainRoutes.js index 9cd26b0e..f783cf21 100644 --- a/web/src/routes/MainRoutes.js +++ b/web/src/routes/MainRoutes.js @@ -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: + }, + { + path: 'telegram', + element: } ] }; diff --git a/web/src/routes/OtherRoutes.js b/web/src/routes/OtherRoutes.js index 085c4add..7531d72d 100644 --- a/web/src/routes/OtherRoutes.js +++ b/web/src/routes/OtherRoutes.js @@ -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: + }, + { + path: '/jump', + element: } ] }; diff --git a/web/src/views/Jump/index.js b/web/src/views/Jump/index.js new file mode 100644 index 00000000..c5f1baf5 --- /dev/null +++ b/web/src/views/Jump/index.js @@ -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
正在跳转中...
; +} diff --git a/web/src/views/Profile/index.js b/web/src/views/Profile/index.js index 5578dddb..bdf4e080 100644 --- a/web/src/views/Profile/index.js +++ b/web/src/views/Profile/index.js @@ -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() { + @@ -209,6 +214,7 @@ export default function Profile() { )} + + + + + )} + + + + ); +}; + +export default EditModal; + +EditModal.propTypes = { + open: PropTypes.bool, + actionId: PropTypes.number, + onCancel: PropTypes.func, + onOk: PropTypes.func +}; diff --git a/web/src/views/Telegram/component/TableRow.js b/web/src/views/Telegram/component/TableRow.js new file mode 100644 index 00000000..86d4fc68 --- /dev/null +++ b/web/src/views/Telegram/component/TableRow.js @@ -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 ( + <> + + {item.id} + + {item.command} + + {item.description} + + {item.parse_mode} + {item.reply_message} + + + + + + + + + + + { + handleCloseMenu(); + handleOpenModal(); + setModalId(item.id); + }} + > + + 编辑 + + + + 删除 + + + + + 删除菜单 + + 是否删除菜单 {item.name}? + + + + + + + + ); +} + +TelegramTableRow.propTypes = { + item: PropTypes.object, + manageAction: PropTypes.func, + handleOpenModal: PropTypes.func, + setModalId: PropTypes.func +}; diff --git a/web/src/views/Telegram/index.js b/web/src/views/Telegram/index.js new file mode 100644 index 00000000..1f903ec0 --- /dev/null +++ b/web/src/views/Telegram/index.js @@ -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 ( + <> + + Telegram Bot菜单 + + + + + 添加修改菜单命令/说明后(如果没有修改命令和说明可以不用重载),需要重新载入菜单才能生效。 + 如果未查看到新菜单,请尝试杀后台后重新启动程序。 + + + + } + label={(status ? '在线' : '离线') + (isWebhook ? '(Webhook)' : '(Polling)')} + color={status ? 'primary' : 'error'} + variant="outlined" + size="small" + /> + + + + + + + + theme.spacing(0, 1, 0, 3) + }} + > + + + + + + + {searching && } + + + + + + {telegramMenus.map((row) => ( + + ))} + +
+
+
+ +
+ + + ); +}