Enhance bot functionality in main.go by adding support for callback queries and user creation flow. Introduce new types for callback queries and inline keyboard markup, and refactor message handling to accommodate these features. Update allowed updates in getUpdates method to include callback queries.
Publish telemt-bot Docker image / build-and-push (push) Successful in 54s
Publish telemt-bot Docker image / build-and-push (push) Successful in 54s
This commit is contained in:
@@ -44,11 +44,12 @@ type config struct {
|
||||
}
|
||||
|
||||
type bot struct {
|
||||
cfg config
|
||||
httpClient *http.Client
|
||||
telemtClient *http.Client
|
||||
telegramBase string
|
||||
offset int64
|
||||
cfg config
|
||||
httpClient *http.Client
|
||||
telemtClient *http.Client
|
||||
telegramBase string
|
||||
offset int64
|
||||
awaitingUsername map[int64]bool
|
||||
}
|
||||
|
||||
type tgGetUpdatesResponse struct {
|
||||
@@ -57,19 +58,42 @@ type tgGetUpdatesResponse struct {
|
||||
}
|
||||
|
||||
type tgUpdate struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *tgMessage `json:"message"`
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *tgMessage `json:"message"`
|
||||
CallbackQuery *tgCallbackQuery `json:"callback_query"`
|
||||
}
|
||||
|
||||
type tgMessage struct {
|
||||
Chat tgChat `json:"chat"`
|
||||
Text string `json:"text"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
Chat tgChat `json:"chat"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type tgChat struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type tgCallbackQuery struct {
|
||||
ID string `json:"id"`
|
||||
From tgUser `json:"from"`
|
||||
Message *tgMessage `json:"message"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type tgUser struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type inlineKeyboardButton struct {
|
||||
Text string `json:"text"`
|
||||
CallbackData string `json:"callback_data,omitempty"`
|
||||
Style string `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
type inlineKeyboardMarkup struct {
|
||||
InlineKeyboard [][]inlineKeyboardButton `json:"inline_keyboard"`
|
||||
}
|
||||
|
||||
type telemtEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
@@ -201,10 +225,11 @@ func main() {
|
||||
}
|
||||
|
||||
b := &bot{
|
||||
cfg: cfg,
|
||||
httpClient: httpClient,
|
||||
telemtClient: telemtClient,
|
||||
telegramBase: "https://api.telegram.org/bot" + cfg.TelegramBotToken,
|
||||
cfg: cfg,
|
||||
httpClient: httpClient,
|
||||
telemtClient: telemtClient,
|
||||
telegramBase: "https://api.telegram.org/bot" + cfg.TelegramBotToken,
|
||||
awaitingUsername: make(map[int64]bool),
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
@@ -278,6 +303,10 @@ func parseIntEnv(key string, fallback int) int {
|
||||
}
|
||||
|
||||
func (b *bot) run(ctx context.Context) error {
|
||||
if err := b.setMyCommands(ctx); err != nil {
|
||||
log.Printf("setMyCommands error: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -297,6 +326,12 @@ func (b *bot) run(ctx context.Context) error {
|
||||
if u.UpdateID >= b.offset {
|
||||
b.offset = u.UpdateID + 1
|
||||
}
|
||||
if u.CallbackQuery != nil {
|
||||
if err := b.handleCallbackQuery(ctx, u.CallbackQuery); err != nil {
|
||||
log.Printf("handleCallbackQuery error: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if u.Message == nil || strings.TrimSpace(u.Message.Text) == "" {
|
||||
continue
|
||||
}
|
||||
@@ -311,7 +346,7 @@ func (b *bot) getUpdates(ctx context.Context) ([]tgUpdate, error) {
|
||||
reqBody := map[string]any{
|
||||
"timeout": defaultListenPollTimeout,
|
||||
"offset": b.offset,
|
||||
"allowed_updates": []string{"message"},
|
||||
"allowed_updates": []string{"message", "callback_query"},
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
@@ -356,13 +391,19 @@ func (b *bot) handleMessage(ctx context.Context, msg *tgMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if b.awaitingUsername[chatID] && !strings.HasPrefix(text, "/") {
|
||||
delete(b.awaitingUsername, chatID)
|
||||
return b.handleCreateUserDirect(ctx, chatID, text)
|
||||
}
|
||||
delete(b.awaitingUsername, chatID)
|
||||
|
||||
command := strings.Fields(text)[0]
|
||||
if at := strings.IndexByte(command, '@'); at > 0 {
|
||||
command = command[:at]
|
||||
}
|
||||
switch command {
|
||||
case "/start", "/help":
|
||||
return b.sendMessage(ctx, chatID, "Команды:\n/health\n/summary\n/users\n/create_user <username>")
|
||||
return b.sendMessageWithKeyboard(ctx, chatID, "Выберите действие:", mainMenuKeyboard())
|
||||
case "/health":
|
||||
return b.handleHealth(ctx, chatID)
|
||||
case "/summary":
|
||||
@@ -372,7 +413,7 @@ func (b *bot) handleMessage(ctx context.Context, msg *tgMessage) error {
|
||||
case "/create_user", "/createuser":
|
||||
return b.handleCreateUser(ctx, chatID, text)
|
||||
default:
|
||||
return b.sendMessage(ctx, chatID, "Неизвестная команда. Используй /help")
|
||||
return b.sendMessageWithKeyboard(ctx, chatID, "Неизвестная команда.", mainMenuKeyboard())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,6 +640,145 @@ func isValidUsername(v string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *bot) handleCallbackQuery(ctx context.Context, cq *tgCallbackQuery) error {
|
||||
if cq.Message == nil {
|
||||
return nil
|
||||
}
|
||||
chatID := cq.Message.Chat.ID
|
||||
msgID := cq.Message.MessageID
|
||||
if chatID != b.cfg.AdminChatID {
|
||||
return b.answerCallbackQuery(ctx, cq.ID, "Доступ запрещён", true)
|
||||
}
|
||||
|
||||
switch cq.Data {
|
||||
case "cmd_health":
|
||||
b.answerCallbackQuery(ctx, cq.ID, "", false)
|
||||
return b.handleHealthCB(ctx, chatID, msgID)
|
||||
case "cmd_summary":
|
||||
b.answerCallbackQuery(ctx, cq.ID, "", false)
|
||||
return b.handleSummaryCB(ctx, chatID, msgID)
|
||||
case "cmd_users":
|
||||
b.answerCallbackQuery(ctx, cq.ID, "", false)
|
||||
return b.handleUsersCB(ctx, chatID, msgID)
|
||||
case "cmd_create_user":
|
||||
b.answerCallbackQuery(ctx, cq.ID, "", false)
|
||||
b.awaitingUsername[chatID] = true
|
||||
return b.editMessageText(ctx, chatID, msgID,
|
||||
"Отправьте username нового пользователя:", cancelKeyboard())
|
||||
case "cmd_menu":
|
||||
b.answerCallbackQuery(ctx, cq.ID, "", false)
|
||||
delete(b.awaitingUsername, chatID)
|
||||
return b.editMessageText(ctx, chatID, msgID, "Выберите действие:", mainMenuKeyboard())
|
||||
default:
|
||||
return b.answerCallbackQuery(ctx, cq.ID, "Неизвестное действие", false)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bot) handleHealthCB(ctx context.Context, chatID, msgID int64) error {
|
||||
var h healthData
|
||||
if err := b.callTelemt(ctx, "/v1/health", &h); err != nil {
|
||||
return b.editMessageText(ctx, chatID, msgID, "Ошибка API: "+err.Error(), backKeyboard())
|
||||
}
|
||||
msg := fmt.Sprintf("Health: %s\nRead-only: %t", h.Status, h.ReadOnly)
|
||||
return b.editMessageText(ctx, chatID, msgID, msg, backKeyboard())
|
||||
}
|
||||
|
||||
func (b *bot) handleSummaryCB(ctx context.Context, chatID, msgID int64) error {
|
||||
var s summaryData
|
||||
if err := b.callTelemt(ctx, "/v1/stats/summary", &s); err != nil {
|
||||
return b.editMessageText(ctx, chatID, msgID, "Ошибка API: "+err.Error(), backKeyboard())
|
||||
}
|
||||
msg := fmt.Sprintf(
|
||||
"Uptime: %.0fs\nConn total: %d\nConn bad: %d\nHandshake timeouts: %d\nUsers: %d",
|
||||
s.UptimeSeconds, s.ConnectionsTotal, s.ConnectionsBadTotal, s.HandshakeTimeoutsTotal, s.ConfiguredUsers,
|
||||
)
|
||||
return b.editMessageText(ctx, chatID, msgID, msg, backKeyboard())
|
||||
}
|
||||
|
||||
func (b *bot) handleUsersCB(ctx context.Context, chatID, msgID int64) error {
|
||||
var users []userInfo
|
||||
if err := b.callTelemt(ctx, "/v1/stats/users", &users); err != nil {
|
||||
return b.editMessageText(ctx, chatID, msgID, "Ошибка API: "+err.Error(), backKeyboard())
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
return b.editMessageText(ctx, chatID, msgID, "Пользователи не найдены.", backKeyboard())
|
||||
}
|
||||
|
||||
const maxRows = 20
|
||||
var sb strings.Builder
|
||||
if len(users) > maxRows {
|
||||
sb.WriteString(fmt.Sprintf("Показаны первые %d из %d\n", maxRows, len(users)))
|
||||
}
|
||||
limit := len(users)
|
||||
if limit > maxRows {
|
||||
limit = maxRows
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
u := users[i]
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
"%d) %s | conn=%d | ips=%d | octets=%d\n",
|
||||
i+1, u.Username, u.CurrentConnections, u.ActiveUniqueIPs, u.TotalOctets,
|
||||
))
|
||||
}
|
||||
return b.editMessageText(ctx, chatID, msgID, strings.TrimRight(sb.String(), "\n"), backKeyboard())
|
||||
}
|
||||
|
||||
func (b *bot) handleCreateUserDirect(ctx context.Context, chatID int64, username string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if !isValidUsername(username) {
|
||||
return b.sendMessageWithKeyboard(ctx, chatID,
|
||||
"Некорректный username. Разрешены [A-Za-z0-9_.-], длина 1..64.", mainMenuKeyboard())
|
||||
}
|
||||
|
||||
req := createUserRequest{Username: username}
|
||||
var created createUserResponse
|
||||
if err := b.callTelemtJSON(ctx, http.MethodPost, "/v1/users", req, &created, http.StatusCreated); err != nil {
|
||||
return b.sendMessageWithKeyboard(ctx, chatID,
|
||||
"Ошибка создания пользователя: "+err.Error(), mainMenuKeyboard())
|
||||
}
|
||||
|
||||
links := b.resolveUserLinks(ctx, created.User.Username, created.Secret, created.User.Links)
|
||||
msg := fmt.Sprintf("Пользователь создан: %s\nsecret: %s", created.User.Username, created.Secret)
|
||||
if linksText := formatUserLinks(links); linksText != "" {
|
||||
msg = msg + "\n\nСсылки:\n" + linksText
|
||||
} else {
|
||||
msg = msg + "\n\nСсылки не вернулись из API."
|
||||
}
|
||||
return b.sendMessageWithKeyboard(ctx, chatID, msg, backKeyboard())
|
||||
}
|
||||
|
||||
func mainMenuKeyboard() *inlineKeyboardMarkup {
|
||||
return &inlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]inlineKeyboardButton{
|
||||
{
|
||||
{Text: "Health", CallbackData: "cmd_health", Style: "primary"},
|
||||
{Text: "Summary", CallbackData: "cmd_summary", Style: "primary"},
|
||||
},
|
||||
{
|
||||
{Text: "Users", CallbackData: "cmd_users", Style: "primary"},
|
||||
{Text: "Create User", CallbackData: "cmd_create_user", Style: "success"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func backKeyboard() *inlineKeyboardMarkup {
|
||||
return &inlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]inlineKeyboardButton{
|
||||
{{Text: "<< Menu", CallbackData: "cmd_menu"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cancelKeyboard() *inlineKeyboardMarkup {
|
||||
return &inlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]inlineKeyboardButton{
|
||||
{{Text: "Cancel", CallbackData: "cmd_menu", Style: "danger"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bot) callTelemt(ctx context.Context, path string, out any) error {
|
||||
return b.callTelemtJSON(ctx, http.MethodGet, path, nil, out, http.StatusOK)
|
||||
}
|
||||
@@ -665,17 +845,65 @@ func (b *bot) callTelemtJSON(ctx context.Context, method, path string, payload a
|
||||
}
|
||||
|
||||
func (b *bot) sendMessage(ctx context.Context, chatID int64, text string) error {
|
||||
return b.sendMessageWithKeyboard(ctx, chatID, text, nil)
|
||||
}
|
||||
|
||||
func (b *bot) sendMessageWithKeyboard(ctx context.Context, chatID int64, text string, keyboard *inlineKeyboardMarkup) error {
|
||||
reqBody := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
}
|
||||
if keyboard != nil {
|
||||
reqBody["reply_markup"] = keyboard
|
||||
}
|
||||
return b.postTelegram(ctx, "/sendMessage", reqBody)
|
||||
}
|
||||
|
||||
func (b *bot) editMessageText(ctx context.Context, chatID, messageID int64, text string, keyboard *inlineKeyboardMarkup) error {
|
||||
reqBody := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"message_id": messageID,
|
||||
"text": text,
|
||||
}
|
||||
if keyboard != nil {
|
||||
reqBody["reply_markup"] = keyboard
|
||||
}
|
||||
return b.postTelegram(ctx, "/editMessageText", reqBody)
|
||||
}
|
||||
|
||||
func (b *bot) answerCallbackQuery(ctx context.Context, callbackQueryID, text string, showAlert bool) error {
|
||||
reqBody := map[string]any{
|
||||
"callback_query_id": callbackQueryID,
|
||||
}
|
||||
if text != "" {
|
||||
reqBody["text"] = text
|
||||
reqBody["show_alert"] = showAlert
|
||||
}
|
||||
return b.postTelegram(ctx, "/answerCallbackQuery", reqBody)
|
||||
}
|
||||
|
||||
func (b *bot) setMyCommands(ctx context.Context) error {
|
||||
commands := []map[string]string{
|
||||
{"command": "start", "description": "Открыть меню"},
|
||||
{"command": "help", "description": "Помощь"},
|
||||
{"command": "health", "description": "Состояние сервера"},
|
||||
{"command": "summary", "description": "Сводка статистики"},
|
||||
{"command": "users", "description": "Список пользователей"},
|
||||
{"command": "create_user", "description": "Создать пользователя"},
|
||||
}
|
||||
reqBody := map[string]any{
|
||||
"commands": commands,
|
||||
}
|
||||
return b.postTelegram(ctx, "/setMyCommands", reqBody)
|
||||
}
|
||||
|
||||
func (b *bot) postTelegram(ctx context.Context, method string, reqBody map[string]any) error {
|
||||
var body bytes.Buffer
|
||||
if err := json.NewEncoder(&body).Encode(reqBody); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.telegramBase+"/sendMessage", &body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.telegramBase+method, &body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -687,7 +915,7 @@ func (b *bot) sendMessage(ctx context.Context, chatID int64, text string) error
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("telegram sendMessage status: %s", resp.Status)
|
||||
return fmt.Errorf("telegram %s status: %s", method, resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user