package main import ( "fmt" "log" "strconv" "strings" "time" api "github.com/OvyFlash/telegram-bot-api" ) const ( AdminJoinRequestMsg = "New join #request from %s [%d]\n\nJoin reason: %s" 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" ) type ExtendedChatJoinRequest struct { *api.ChatJoinRequest JoinReason string JoinRequestMessageID int } type Bot struct { API *api.BotAPI WaitingForApproval map[int64]*ExtendedChatJoinRequest Config Config } func main() { b := &Bot{} b.Config = Config{} err := b.Config.LoadConfig() if err != nil { log.Fatal(err.Error()) } bot, err := api.NewBotAPI(b.Config.BotToken) if err != nil { panic(err) } b.API = bot b.WaitingForApproval = make(map[int64]*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 && update.Message.ReplyToMessage != nil { // handle admin declineion reason repliedMsg := update.Message.ReplyToMessage if strings.Contains(repliedMsg.Text, "(no reason provided, reply to this to send one, prepend with + to also send to user)") { // now we need to make sure the one that declined it is the one replying lines := strings.Split(repliedMsg.Text, "\n") if strings.TrimPrefix(lines[3], "Declined by: ") == update.Message.From.String() { reason := escapeHTML(update.Message.Text) userID, username, joinReason, declinedBy, declinedAt := GetInfoFromMsg(repliedMsg.Text) if strings.HasPrefix(update.Message.Text, "+") { reason = escapeHTML(update.Message.Text[1:]) m := api.NewMessage(userID, fmt.Sprintf("Your join request was declined for the following reason:\n\n%s", reason)) b.API.Send(m) } edit := api.NewEditMessageText(b.Config.AdminChatId, repliedMsg.MessageID, fmt.Sprintf(AdminDeclinedMsg, username, userID, joinReason, declinedBy, declinedAt, reason)) edit.ParseMode = api.ModeHTML b.API.Send(edit) } } } } } func (bot *Bot) handleJoinRequestResponse(user *ExtendedChatJoinRequest, update *api.Message) { if user.JoinReason == "" { user.JoinReason = escapeHTML(update.Text) userString := "" if user.From.UserName != "" { userString = "@" + user.From.UserName } else { userString = fmt.Sprintf("%s %s", user.From.FirstName, user.From.LastName) } edit := api.NewEditMessageText(bot.Config.AdminChatId, user.JoinRequestMessageID, fmt.Sprintf(AdminJoinRequestMsg, userString, user.From.ID, user.JoinReason)) keyboard := newApprovalKeyboard(user.From.ID) edit.ReplyMarkup = &keyboard edit.ParseMode = api.ModeHTML bot.API.Send(edit) ack := api.NewMessage(update.From.ID, "Thank you! Your request has been sent to the admins for review.") bot.API.Send(ack) } else { m := api.NewMessage(update.From.ID, "Your request is already pending approval.") bot.API.Send(m) } } func (bot *Bot) handleJoinRequest(request *api.ChatJoinRequest) { bot.WaitingForApproval[request.From.ID] = &ExtendedChatJoinRequest{ ChatJoinRequest: request, JoinReason: "", } m := api.NewMessage(request.From.ID, bot.Config.EntryMessage) m.ParseMode = api.ModeHTML bot.API.Send(m) userString := "" if request.From.UserName != "" { userString = "@" + request.From.UserName } else { userString = fmt.Sprintf("%s %s", request.From.FirstName, request.From.LastName) } m = api.NewMessage(bot.Config.AdminChatId, fmt.Sprintf(AdminJoinRequestMsg, userString, request.From.ID, "(awaiting user response)")) m.ReplyMarkup = newApprovalKeyboard(request.From.ID) m.ParseMode = api.ModeHTML if bot.Config.AdminChatTopicId != 0 { m.MessageThreadID = bot.Config.AdminChatTopicId } r, _ := bot.API.Send(m) bot.WaitingForApproval[request.From.ID].JoinRequestMessageID = r.MessageID } func (bot *Bot) handleCallbackQuery(query *api.CallbackQuery) { data := strings.Join(strings.Split(query.Data, "_"), " ") var userId int64 var action string _, err := fmt.Sscanf(data, "%s %d", &action, &userId) if err != nil { log.Printf("Failed to parse callback data: %v", err) return } // handle callbacks from admin chat if query.Message.Chat.ID == bot.Config.AdminChatId { user, exists := bot.WaitingForApproval[userId] if !exists { log.Printf("No pending request for user ID %d", userId) return } switch action { case "approve": userString := "" if user.From.UserName != "" { userString = "@" + user.From.UserName } else { userString = fmt.Sprintf("%s %s (no username)", user.From.FirstName, user.From.LastName) } r := api.ApproveChatJoinRequestConfig{ 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(bot.Config.AdminChatId, user.JoinRequestMessageID, fmt.Sprintf(AdminFailedMsg, userString, user.From.ID, user.JoinReason, "User not found in requests.")) edit.ParseMode = api.ModeHTML bot.API.Send(edit) callback := api.NewCallback(query.ID, "Join request failed.") bot.API.Request(callback) return } approveUserString := "" if query.From.UserName != "" { approveUserString = "@" + query.From.UserName } else { approveUserString = fmt.Sprintf("%s %s", query.From.FirstName, query.From.LastName) } edit := api.NewEditMessageText(bot.Config.AdminChatId, user.JoinRequestMessageID, fmt.Sprintf(AdminApprovedMsg, userString, user.From.ID, user.JoinReason, approveUserString, time.Now().Format("2006-01-02 15:04:05"))) edit.ParseMode = api.ModeHTML bot.API.Send(edit) if bot.Config.ApprovalMessage != "" { m := api.NewMessage(user.From.ID, bot.Config.ApprovalMessage) m.ParseMode = api.ModeHTML bot.API.Send(m) } callback := api.NewCallback(query.ID, "Join request approved.") bot.API.Request(callback) case "decline": userString := "" if user.From.UserName != "" { userString = "@" + user.From.UserName } else { userString = fmt.Sprintf("%s %s (no username)", user.From.FirstName, user.From.LastName) } r := api.DeclineChatJoinRequest{ 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(bot.Config.AdminChatId, user.JoinRequestMessageID, fmt.Sprintf(AdminFailedMsg, userString, user.From.ID, user.JoinReason, "User not found in requests.")) edit.ParseMode = api.ModeHTML bot.API.Send(edit) callback := api.NewCallback(query.ID, "Join request failed.") bot.API.Request(callback) return } declinedUserString := "" if query.From.UserName != "" { declinedUserString = "@" + query.From.UserName } else { declinedUserString = fmt.Sprintf("%s %s", query.From.FirstName, query.From.LastName) } edit := api.NewEditMessageText(bot.Config.AdminChatId, user.JoinRequestMessageID, fmt.Sprintf(AdminDeclinedMsg, userString, user.From.ID, user.JoinReason, declinedUserString, time.Now().Format("2006-01-02 15:04:05"), "(no reason provided, reply to this to send one, prepend with + to also send to user)")) edit.ParseMode = api.ModeHTML bot.API.Send(edit) callback := api.NewCallback(query.ID, "Join request declined.") bot.API.Request(callback) } if bot.Config.DeleteRequestAfterDecision { deleteTimer := time.NewTimer(10 * time.Second) go func() { <-deleteTimer.C del := api.NewDeleteMessage(bot.Config.AdminChatId, user.JoinRequestMessageID) bot.API.Send(del) }() } delete(bot.WaitingForApproval, userId) } } 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...) 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...) return replacer.Replace(s) } func newApprovalKeyboard(userID int64) api.InlineKeyboardMarkup { approveBtn := api.NewInlineKeyboardButtonData("Approve", fmt.Sprintf("approve_%d", userID)) declineBtn := api.NewInlineKeyboardButtonData("Decline", fmt.Sprintf("decline_%d", userID)) return api.NewInlineKeyboardMarkup([]api.InlineKeyboardButton{approveBtn, declineBtn}) } func GetInfoFromMsg(msg string) (userId int64, username, joinReason, declinedBy, declinedAt string) { start := strings.Index(msg, "[") end := strings.Index(msg, "]") userID, _ := strconv.Atoi(msg[start+1 : end]) username = msg[31 : start-1] lines := strings.Split(msg, "\n") joinReason = strings.TrimPrefix(lines[2], "Join reason: ") declinedBy = strings.TrimPrefix(lines[3], "Declined by: ") declinedAt = strings.TrimPrefix(lines[4], "Declined at: ") return int64(userID), username, joinReason, declinedBy, declinedAt }