diff --git a/handlers/admin.go b/handlers/admin.go index 42bf378..d25d57e 100644 --- a/handlers/admin.go +++ b/handlers/admin.go @@ -19,12 +19,10 @@ func (bot *Bot) HandleAdminCommands(update *api.Update) { } _, e := utils.SendMessage(bot.API, bot.API.Self.ID, 0, update.Message.CommandArguments()) - if e != nil { - if strings.HasPrefix(e.Error(), "Bad Request:") { - utils.SendMessage(bot.API, update.Message.Chat.ID, update.Message.MessageThreadID, - fmt.Sprintf("Unable to set entry message: %s", e)) - return - } + if e != nil && strings.HasPrefix(e.Error(), "Bad Request:") { + utils.SendMessage(bot.API, update.Message.Chat.ID, update.Message.MessageThreadID, + fmt.Sprintf("Unable to set entry message: %s", e)) + return } bot.Config.EntryMessage = update.Message.CommandArguments() @@ -57,23 +55,19 @@ func (bot *Bot) HandleAdminCommands(update *api.Update) { update.Message.Chat.ID, topicID)) case "togglesendapproval": - if bot.Config.ApprovalMessage != "" { - switch bot.Config.SendApprovalMessage { - case true: - bot.Config.SendApprovalMessage = false - case false: - bot.Config.SendApprovalMessage = true - } - if err := bot.Config.SaveConfig(); err != nil { - log.Printf("Failed to save config: %v", err) - } - utils.SendMessage(bot.API, update.Message.Chat.ID, update.Message.MessageThreadID, - fmt.Sprintf("Send approval message: %v", bot.Config.SendApprovalMessage)) - } else { + if bot.Config.ApprovalMessage == "" { utils.SendMessage(bot.API, update.Message.Chat.ID, update.Message.MessageThreadID, "Please set an approval message with /setapprovalmessage") + return } + bot.Config.SendApprovalMessage = !bot.Config.SendApprovalMessage + if err := bot.Config.SaveConfig(); err != nil { + log.Printf("Failed to save config: %v", err) + } + utils.SendMessage(bot.API, update.Message.Chat.ID, update.Message.MessageThreadID, + fmt.Sprintf("Send approval message: %v", bot.Config.SendApprovalMessage)) + case "setapprovalmessage": if update.Message.CommandArguments() == "" { utils.SendMessage(bot.API, update.Message.Chat.ID, update.Message.MessageThreadID, @@ -93,12 +87,14 @@ func (bot *Bot) HandleAdminCommands(update *api.Update) { if *bot.Config.TargetChatId != 0 { targetChatID = fmt.Sprintf("%d", *bot.Config.TargetChatId) } - infoMsg := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", - fmt.Sprintf("Admin Chat ID: %d", update.Message.Chat.ID), - fmt.Sprintf("Admin Topic ID: %d", *bot.Config.AdminChatTopicId), - fmt.Sprintf("Target Chat ID: %s", targetChatID), - fmt.Sprintf("Entry Message: %s", bot.Config.EntryMessage), - fmt.Sprintf("Approval Message: %s", bot.Config.ApprovalMessage)) - utils.SendMessage(bot.API, update.Message.Chat.ID, update.Message.MessageThreadID, infoMsg) + utils.SendMessage(bot.API, update.Message.Chat.ID, update.Message.MessageThreadID, + fmt.Sprintf( + "Admin Chat ID: %d\nAdmin Topic ID: %d\nTarget Chat ID: %s\nEntry Message: %s\nApproval Message: %s", + update.Message.Chat.ID, + *bot.Config.AdminChatTopicId, + targetChatID, + bot.Config.EntryMessage, + bot.Config.ApprovalMessage, + )) } } diff --git a/handlers/callbacks.go b/handlers/callbacks.go index 3327b50..b7e2d8b 100644 --- a/handlers/callbacks.go +++ b/handlers/callbacks.go @@ -12,37 +12,21 @@ import ( // HandleCallbackQuery processes inline button callbacks (approve/decline/leave). func (bot *Bot) HandleCallbackQuery(query *api.CallbackQuery) { - data := strings.Join(strings.Split(query.Data, "_"), " ") - var action string - var args int64 - _, err := fmt.Sscanf(data, "%s %d", &action, &args) + action, args, err := parseCallbackData(query.Data) if err != nil { log.Printf("Failed to parse callback data: %v", err) return } if action == "leave" { - utils.LeaveChatRequest(bot.API, []int64{args}) - utils.EditMessage(bot.API, query.Message.Chat.ID, query.Message.MessageID, - fmt.Sprintf("We have left chat %d", args)) - callback := api.NewCallback(query.ID, "") - bot.API.Request(callback) + bot.handleLeaveAction(query, args) return } user := bot.GetPendingUser(args) if user == nil { log.Printf("No pending request for user ID %d", args) - msg := api.NewMessage(query.Message.Chat.ID, "Unable to find user, bot may have restarted") - msg.ReplyParameters = api.ReplyParameters{MessageID: query.Message.MessageID, ChatID: query.Message.Chat.ID} - r, _ := bot.API.Send(msg) - - edit := api.NewEditMessageText(query.Message.Chat.ID, r.ReplyToMessage.MessageID, r.ReplyToMessage.Text) - edit.Entities = r.ReplyToMessage.Entities - bot.API.Send(edit) - - callback := api.NewCallback(query.ID, "") - bot.API.Request(callback) + bot.handleMissingUser(query) return } @@ -56,98 +40,83 @@ func (bot *Bot) HandleCallbackQuery(query *api.CallbackQuery) { bot.handleDeclineRequest(query, user, userString, adminUserString) } - if bot.Config.DeleteRequestAfterDecision { - deleteTimer := time.NewTimer(10 * time.Second) - go func() { - defer deleteTimer.Stop() - <-deleteTimer.C - del := api.NewDeleteMessage(query.Message.Chat.ID, query.Message.MessageID) - bot.API.Send(del) - }() - } - bot.DeletePendingUser(args) + + if bot.Config.DeleteRequestAfterDecision { + go bot.scheduleMessageDeletion(query.Message.Chat.ID, query.Message.MessageID, 10*time.Second) + } } -// handleApproveRequest approves a join request and sends approval callback. +// handleApproveRequest approves a join request and sends an approval callback. func (bot *Bot) handleApproveRequest(query *api.CallbackQuery, user *ExtendedChatJoinRequest, userString, adminUserString string) { r := api.ApproveChatJoinRequestConfig{ - ChatConfig: api.ChatConfig{ - ChatID: user.ChatJoinRequest.Chat.ID, - }, - UserID: user.ChatJoinRequest.From.ID, + ChatConfig: api.ChatConfig{ChatID: user.ChatJoinRequest.Chat.ID}, + UserID: user.ChatJoinRequest.From.ID, } - _, e := bot.API.Request(r) - if e != nil { - log.Println(e.Error()) - edit := api.NewEditMessageText(query.Message.Chat.ID, query.Message.MessageID, query.Message.Text) - edit.Entities = query.Message.Entities - bot.API.Send(edit) + + if _, err := bot.API.Request(r); err != nil { + log.Println(err) + bot.restoreMessage(query) return } utils.EditMessage(bot.API, query.Message.Chat.ID, query.Message.MessageID, fmt.Sprintf(AdminApprovedMsg, userString, user.From.ID, user.JoinReason, adminUserString, - time.Now().Format("2006-01-02 15:04:05"))) + time.Now().Format("2006-01-02 15:04:05"), + ), + ) if bot.Config.SendApprovalMessage { utils.SendMessage(bot.API, user.From.ID, 0, bot.Config.ApprovalMessage) } - callback := api.NewCallback(query.ID, "Join request approved.") - bot.API.Request(callback) + bot.API.Request(api.NewCallback(query.ID, "Join request approved.")) } -// handleDeclineRequest declines a join request and sends decline callback. +// handleDeclineRequest declines a join request and sends a decline callback. func (bot *Bot) handleDeclineRequest(query *api.CallbackQuery, user *ExtendedChatJoinRequest, userString, adminUserString string) { r := api.DeclineChatJoinRequest{ - ChatConfig: api.ChatConfig{ - ChatID: user.ChatJoinRequest.Chat.ID, - }, - UserID: user.ChatJoinRequest.From.ID, + ChatConfig: api.ChatConfig{ChatID: user.ChatJoinRequest.Chat.ID}, + UserID: user.ChatJoinRequest.From.ID, } - _, e := bot.API.Request(r) - if e != nil { - log.Println(e.Error()) - edit := api.NewEditMessageText(query.Message.Chat.ID, query.Message.MessageID, query.Message.Text) - edit.Entities = query.Message.Entities - bot.API.Send(edit) + + if _, err := bot.API.Request(r); err != nil { + log.Println(err) + bot.restoreMessage(query) return } utils.EditMessage(bot.API, query.Message.Chat.ID, query.Message.MessageID, - fmt.Sprintf(AdminDeclinedMsg, userString, user.From.ID, user.JoinReason, adminUserString, + fmt.Sprintf(AdminDeclinedMsg, + userString, user.From.ID, user.JoinReason, adminUserString, time.Now().Format("2006-01-02 15:04:05"), - "(no reason provided, reply to this to set one, prepend with + to also send to user)"), + defaultReason, + ), ) - callback := api.NewCallback(query.ID, "Join request declined.") - bot.API.Request(callback) + bot.API.Request(api.NewCallback(query.ID, "Join request declined.")) } // HandleDeclineReason allows admins to provide a decline reason by replying to decline messages. func (bot *Bot) HandleDeclineReason(update *api.Update) { repliedMsg := update.Message.ReplyToMessage - if !strings.Contains(repliedMsg.Text, - "(no reason provided, reply to this to set one, prepend with + to also send to user)") { + if !strings.Contains(repliedMsg.Text, defaultReason) { return } lines := strings.Split(repliedMsg.Text, "\n") userString := utils.BuildUserString(update.Message.From) - if strings.TrimPrefix(lines[3], "Declined by: ") != userString { return } - reason := utils.EscapeHTML(update.Message.Text) userID, username, joinReason, declinedBy, declinedAt := utils.GetInfoFromMsg(repliedMsg.Text) - entities, _ := utils.FilterEntitiesByTypeWithContext(repliedMsg, "italic") - if len(entities) >= 1 { + if entities, _ := utils.FilterEntitiesByTypeWithContext(repliedMsg, "italic"); len(entities) >= 1 { username = fmt.Sprintf("%s", entities[0].Text) } + reason := utils.EscapeHTML(update.Message.Text) if strings.HasPrefix(update.Message.Text, "+") { reason = utils.EscapeHTML(update.Message.Text[1:]) utils.SendMessage(bot.API, userID, 0, @@ -157,3 +126,49 @@ func (bot *Bot) HandleDeclineReason(update *api.Update) { utils.EditMessage(bot.API, update.Message.Chat.ID, repliedMsg.MessageID, fmt.Sprintf(AdminDeclinedMsg, username, userID, joinReason, declinedBy, declinedAt, reason)) } + +// parseCallbackData parses the action and user ID from a callback query's data string. +func parseCallbackData(data string) (action string, userID int64, err error) { + normalized := strings.Join(strings.Split(data, "_"), " ") + _, err = fmt.Sscanf(normalized, "%s %d", &action, &userID) + return +} + +// handleLeaveAction handles the "leave" callback by leaving the specified chat. +func (bot *Bot) handleLeaveAction(query *api.CallbackQuery, chatID int64) { + utils.LeaveChatRequest(bot.API, []int64{chatID}) + utils.EditMessage(bot.API, query.Message.Chat.ID, query.Message.MessageID, + fmt.Sprintf("We have left chat %d", chatID)) + bot.API.Request(api.NewCallback(query.ID, "")) +} + +// handleMissingUser notifies the admin that the user's pending request could not be found. +func (bot *Bot) handleMissingUser(query *api.CallbackQuery) { + msg := api.NewMessage(query.Message.Chat.ID, "Unable to find user, bot may have restarted") + msg.ReplyParameters = api.ReplyParameters{ + MessageID: query.Message.MessageID, + ChatID: query.Message.Chat.ID, + } + r, _ := bot.API.Send(msg) + + edit := api.NewEditMessageText(query.Message.Chat.ID, r.ReplyToMessage.MessageID, r.ReplyToMessage.Text) + edit.Entities = r.ReplyToMessage.Entities + bot.API.Send(edit) + + bot.API.Request(api.NewCallback(query.ID, "")) +} + +// restoreMessage restores a message to its original state after a failed API request. +func (bot *Bot) restoreMessage(query *api.CallbackQuery) { + edit := api.NewEditMessageText(query.Message.Chat.ID, query.Message.MessageID, query.Message.Text) + edit.Entities = query.Message.Entities + bot.API.Send(edit) +} + +// scheduleMessageDeletion deletes a message after a given delay. +func (bot *Bot) scheduleMessageDeletion(chatID int64, messageID int, delay time.Duration) { + timer := time.NewTimer(delay) + defer timer.Stop() + <-timer.C + bot.API.Send(api.NewDeleteMessage(chatID, messageID)) +} diff --git a/handlers/handlers.go b/handlers/handlers.go index 661a9f2..ef704a6 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -12,6 +12,7 @@ const ( AdminApprovedMsg = "✅ Join #request approved for %s [%d]\n\nJoin reason: %s\nApproved by: %s\nApproved at: %s" AdminDeclinedMsg = "❌ Join #request declined for %s [%d]\n\nJoin reason: %s\nDeclined by: %s\nDeclined at: %s\nDeclined reason: %s" AdminFailedMsg = "⚠️ Join #request failed for %s [%d]\n\nJoin reason: %s\nFailure reason: %s" + defaultReason = "(no reason provided, reply to this to set one, prepend with + to also send to user)" ) // Types shared by handler files @@ -32,20 +33,13 @@ type Bot struct { func (bot *Bot) GetPendingUser(userID int64) *ExtendedChatJoinRequest { bot.mu.RLock() defer bot.mu.RUnlock() - user := bot.WaitingForApproval[userID] - if user == nil { - return nil - } - return user + return bot.WaitingForApproval[userID] } // SetPendingUser stores a pending user request (write-safe). func (bot *Bot) SetPendingUser(userID int64, user *ExtendedChatJoinRequest) { bot.mu.Lock() defer bot.mu.Unlock() - if _, ok := bot.WaitingForApproval[userID]; !ok { - bot.WaitingForApproval = make(map[int64]*ExtendedChatJoinRequest) - } bot.WaitingForApproval[userID] = user } diff --git a/handlers/join.go b/handlers/join.go index a67baa4..fc84837 100644 --- a/handlers/join.go +++ b/handlers/join.go @@ -10,35 +10,23 @@ import ( // HandleJoinRequestResponse records the user's join reason and notifies admins. func (bot *Bot) HandleJoinRequestResponse(user *ExtendedChatJoinRequest, update *api.Message) { - if user.JoinReason == "" { - user.JoinReason = utils.EscapeHTML(update.Text) - userString := utils.BuildUserString(&user.From) - - keyboard := utils.NewApprovalKeyboard(user.From.ID) - utils.EditMessageWithKeyboard(bot.API, *bot.Config.AdminChatId, user.JoinRequestMessageID, - fmt.Sprintf(AdminJoinRequestMsg, - userString, user.From.ID, user.JoinReason), &keyboard) - - utils.SendMessage(bot.API, update.From.ID, 0, "Thank you! Your request has been sent to the admins for review.") - } else { + if user.JoinReason != "" { utils.SendMessage(bot.API, update.From.ID, 0, "Your request is already pending approval.") - } -} - -// HandleJoinRequest initiates join approval flow by sending entry message and admin notification. -func (bot *Bot) HandleJoinRequest(request *api.ChatJoinRequest) { - // if chat is not in config, ignore - if *bot.Config.TargetChatId != request.Chat.ID { - m := api.NewMessage(*bot.Config.AdminChatId, - fmt.Sprintf("Received join request for chat %s (%d), but it's not in config, ignoring", - request.Chat.Title, request.Chat.ID)) - leaveBtn := api.NewInlineKeyboardButtonData("Leave Chat", fmt.Sprintf("leave_%d", request.Chat.ID)) - m.ReplyMarkup = api.NewInlineKeyboardMarkup([]api.InlineKeyboardButton{leaveBtn}) - m.ParseMode = api.ModeHTML - bot.API.Send(m) return } + user.JoinReason = utils.EscapeHTML(update.Text) + userString := utils.BuildUserString(&user.From) + + keyboard := utils.NewApprovalKeyboard(user.From.ID) + utils.EditMessageWithKeyboard(bot.API, *bot.Config.AdminChatId, user.JoinRequestMessageID, + fmt.Sprintf(AdminJoinRequestMsg, userString, user.From.ID, user.JoinReason), &keyboard) + + utils.SendMessage(bot.API, update.From.ID, 0, "Thank you! Your request has been sent to the admins for review.") +} + +// HandleJoinRequest initiates the join approval flow by sending the entry message and admin notification. +func (bot *Bot) HandleJoinRequest(request *api.ChatJoinRequest) { utils.SendMessage(bot.API, request.From.ID, 0, bot.Config.EntryMessage) userString := utils.BuildUserString(&request.From) @@ -49,6 +37,7 @@ func (bot *Bot) HandleJoinRequest(request *api.ChatJoinRequest) { if topic := *bot.Config.AdminChatTopicId; topic != 0 { m.MessageThreadID = topic } + r, err := bot.API.Send(m) if err != nil { log.Printf("Failed to send join request to admin chat: %v", err) @@ -66,7 +55,5 @@ func (bot *Bot) HandleJoinRequest(request *api.ChatJoinRequest) { func (bot *Bot) SendFailureMessage(user *ExtendedChatJoinRequest, query *api.CallbackQuery, userString string) { utils.EditMessage(bot.API, *bot.Config.AdminChatId, user.JoinRequestMessageID, fmt.Sprintf(AdminFailedMsg, userString, user.From.ID, utils.EscapeHTML(user.JoinReason), "User not found in requests.")) - - callback := api.NewCallback(query.ID, "Join request failed.") - bot.API.Request(callback) + bot.API.Request(api.NewCallback(query.ID, "Join request failed.")) } diff --git a/main.go b/main.go index 5246861..cbed5cf 100644 --- a/main.go +++ b/main.go @@ -9,58 +9,63 @@ import ( ) func main() { - b := &handlers.Bot{} - b.Config = config.Config{} - err := b.Config.LoadConfig() - if err != nil { - log.Fatal(err.Error()) + cfg := config.Config{} + if err := cfg.LoadConfig(); err != nil { + log.Fatal(err) } - if b.Config.BotToken == nil { + if cfg.BotToken == nil { log.Fatal("Edit config.yaml and fill out bot token") } - bot, err := api.NewBotAPI(*b.Config.BotToken) + + bot, err := api.NewBotAPI(*cfg.BotToken) if err != nil { - panic(err) + log.Fatal(err) } - b.API = bot - b.WaitingForApproval = make(map[int64]*handlers.ExtendedChatJoinRequest) + b := &handlers.Bot{ + API: bot, + Config: cfg, + WaitingForApproval: make(map[int64]*handlers.ExtendedChatJoinRequest), + } log.Printf("Authorized on account %s", bot.Self.UserName) updateConfig := api.NewUpdate(0) updateConfig.Timeout = 60 - updatesChannel := b.API.GetUpdatesChan(updateConfig) - for update := range updatesChannel { - if update.ChatJoinRequest != nil { - if update.ChatJoinRequest.Chat.ID == *b.Config.TargetChatId { - b.HandleJoinRequest(update.ChatJoinRequest) - } - continue - } - - if update.CallbackQuery != nil { - b.HandleCallbackQuery(update.CallbackQuery) - continue - } - - if update.Message == nil { - continue - } - - if user, ok := b.WaitingForApproval[update.Message.From.ID]; ok { - if update.Message.Chat.ID == update.Message.From.ID { - b.HandleJoinRequestResponse(user, update.Message) - } - } - - if update.Message.Chat.ID == *b.Config.AdminChatId { - if update.Message.ReplyToMessage != nil { - b.HandleDeclineReason(&update) - } - b.HandleAdminCommands(&update) - } + for update := range b.API.GetUpdatesChan(updateConfig) { + processUpdate(b, update) + } +} + +func processUpdate(b *handlers.Bot, update api.Update) { + if update.ChatJoinRequest != nil { + if update.ChatJoinRequest.Chat.ID == *b.Config.TargetChatId { + b.HandleJoinRequest(update.ChatJoinRequest) + } + return + } + + if update.CallbackQuery != nil { + b.HandleCallbackQuery(update.CallbackQuery) + return + } + + if update.Message == nil || update.Message.From == nil { + return + } + + if user := b.GetPendingUser(update.Message.From.ID); user != nil { + if update.Message.Chat.ID == update.Message.From.ID { + b.HandleJoinRequestResponse(user, update.Message) + } + } + + if update.Message.Chat.ID == *b.Config.AdminChatId { + if update.Message.ReplyToMessage != nil { + b.HandleDeclineReason(&update) + } + b.HandleAdminCommands(&update) } } diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 0c646b3..5ec3057 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -10,27 +10,28 @@ import ( ) func EscapeMarkdown(s string) string { - toEscape := []string{"*", "_", "`", "[", "]", "(", ")", "\\", "#", "-"} - - replacements := make([]string, 0, len(toEscape)*2) - for _, char := range toEscape { - replacements = append(replacements, char, "\\"+char) - } - - replacer := strings.NewReplacer(replacements...) + replacer := strings.NewReplacer( + "\\", "\\\\", + "*", "\\*", + "_", "\\_", + "`", "\\`", + "[", "\\[", + "]", "\\]", + "(", "\\(", + ")", "\\)", + "#", "\\#", + "-", "\\-", + ) return replacer.Replace(s) } func EscapeHTML(s string) string { - toEscape := []string{"&", "<", ">", "\"", "'"} - - replacements := make([]string, 0, len(toEscape)*2) - replacements = append(replacements, "&", "&") - replacements = append(replacements, "<", "<") - replacements = append(replacements, ">", ">") - replacements = append(replacements, "\"", """) - - replacer := strings.NewReplacer(replacements...) + replacer := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + "\"", """, + ) return replacer.Replace(s) } @@ -109,7 +110,7 @@ func BuildUserString(user *api.User) string { return name.String() } -func SendMessage(botAPI *api.BotAPI, chatID int64, topicID int, text string) (resp api.Message, err error) { +func SendMessage(botAPI *api.BotAPI, chatID int64, topicID int, text string) (api.Message, error) { msg := api.NewMessage(chatID, text) msg.ParseMode = api.ModeHTML if topicID != 0 { @@ -118,22 +119,20 @@ func SendMessage(botAPI *api.BotAPI, chatID int64, topicID int, text string) (re return botAPI.Send(msg) } -func EditMessage(botAPI *api.BotAPI, chatID int64, messageID int, text string) (resp api.Message, err error) { +func EditMessage(botAPI *api.BotAPI, chatID int64, messageID int, text string) (api.Message, error) { edit := api.NewEditMessageText(chatID, messageID, text) edit.ParseMode = api.ModeHTML return botAPI.Send(edit) } func LeaveChatRequest(botAPI *api.BotAPI, chatIDs []int64) error { - var err error for _, chatID := range chatIDs { - leaveChatConfig := api.LeaveChatConfig{ChatConfig: api.ChatConfig{ChatID: chatID}} - _, err = botAPI.Request(leaveChatConfig) + _, err := botAPI.Request(api.LeaveChatConfig{ChatConfig: api.ChatConfig{ChatID: chatID}}) if err != nil { return err } } - return err + return nil } func EditMessageWithKeyboard(botAPI *api.BotAPI, chatID int64, messageID int, text string, keyboard *api.InlineKeyboardMarkup) { @@ -145,8 +144,7 @@ func EditMessageWithKeyboard(botAPI *api.BotAPI, chatID int64, messageID int, te botAPI.Send(edit) } -// ParseIntArg parses a string argument as int, returns (value, error message). -// Error message is empty on success. +// ParseIntArg parses a string argument as int. Returns (value, errMsg); errMsg is empty on success. func ParseIntArg(arg string) (int, string) { val, err := strconv.Atoi(arg) if err != nil { @@ -155,8 +153,7 @@ func ParseIntArg(arg string) (int, string) { return val, "" } -// ParseInt64Arg parses a string argument as int64, returns (value, error message). -// Error message is empty on success. +// ParseInt64Arg parses a string argument as int64. Returns (value, errMsg); errMsg is empty on success. func ParseInt64Arg(arg string) (int64, string) { val, err := strconv.ParseInt(arg, 10, 64) if err != nil {