--- a/config/config.go +++ b/config/config.go @@ -8,14 +8,16 @@ ) type Config struct { - BotToken *string `yaml:"bot_token"` - AdminChatId *int64 `yaml:"admin_chat_id"` - AdminChatTopicId *int `yaml:"admin_chat_topic_id"` - TargetChatId *int64 `yaml:"target_chat_id"` - EntryMessage string `yaml:"entry_message"` - ApprovalMessage string `yaml:"approval_message"` - SendApprovalMessage bool `yaml:"send_approval_message"` - DeleteRequestAfterDecision bool `yaml:"delete_request_after_decision"` + BotToken *string `yaml:"bot_token"` + AdminChatId *int64 `yaml:"admin_chat_id"` + AdminChatTopicId *int `yaml:"admin_chat_topic_id"` + TargetChatId *int64 `yaml:"target_chat_id"` + EntryMessage string `yaml:"entry_message"` + ApprovalMessage string `yaml:"approval_message"` + ReminderMessage string `yaml:"reminder_message"` + SendApprovalMessage bool `yaml:"send_approval_message"` + DeleteRequestAfterDecision bool `yaml:"delete_request_after_decision"` + CannedDeclineResponses []string `yaml:"canned_decline_responses"` } func (c *Config) LoadConfig() error { @@ -43,10 +45,12 @@ AdminChatId: Int64Ptr(0), AdminChatTopicId: IntPtr(0), TargetChatId: Int64Ptr(0), - EntryMessage: "You have requested to join the group, please write a brief message explaining why you want to join.", + EntryMessage: "You have requested to join the group. Please write a brief message explaining why you want to join.", + ReminderMessage: "Don't forget to give a one-message response to your join request.", ApprovalMessage: "", SendApprovalMessage: false, DeleteRequestAfterDecision: false, + CannedDeclineResponses: []string{}, } encoder := yaml.NewEncoder(f) --- a/handlers/callbacks.go +++ b//handlers/callbacks.go @@ -39,7 +39,6 @@ bot.DeletePendingUser(args) case "decline": bot.handleDeclineRequest(query, user, userString, adminUserString) - bot.DeletePendingUser(args) case "ban": bot.showBanConfirmation(query, user) bot.API.Request(api.NewCallback(query.ID, "")) @@ -47,6 +46,19 @@ case "banc": bot.handleBanRequest(query, user, userString, adminUserString) bot.DeletePendingUser(args) + case "remind": + bot.sendReminder(query, user, userString, adminUserString) + bot.API.Request(api.NewCallback(query.ID, "Reminder sent!")) + case "cannedrespsel": + parts := strings.Split(query.Data, "_") + if len(parts) >= 3 { + var respIdx int + fmt.Sscanf(parts[2], "%d", &respIdx) + bot.sendCannedResponse(query, user, respIdx) + } + bot.DeletePendingUser(args) + bot.API.Request(api.NewCallback(query.ID, "")) + return } if bot.Config.DeleteRequestAfterDecision { @@ -54,6 +66,22 @@ } } +func (bot *Bot) sendReminder(query *api.CallbackQuery, user *ExtendedChatJoinRequest, userString, adminUserString string) { + utils.SendMessage(bot.API, user.From.ID, 0, bot.Config.ReminderMessage) + + // Edit admin message to show reminder was sent + messageText := fmt.Sprintf(AdminJoinRequestMsg, userString, user.From.ID, user.JoinReason) + messageText += fmt.Sprintf("\n\nReminder sent by: %s\nReminder sent at: %s", + adminUserString, time.Now().Format("2006-01-02 15:04:05")) + + keyboard := utils.NewApprovalKeyboard(user.From.ID) + + edit := api.NewEditMessageText(query.Message.Chat.ID, query.Message.MessageID, messageText) + edit.ParseMode = api.ModeHTML + edit.ReplyMarkup = &keyboard + bot.API.Send(edit) +} + // 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{ @@ -94,14 +122,39 @@ return } - utils.EditMessage(bot.API, query.Message.Chat.ID, query.Message.MessageID, - fmt.Sprintf(AdminDeclinedMsg, - userString, user.From.ID, user.JoinReason, adminUserString, - time.Now().Format("2006-01-02 15:04:05"), - defaultReason, - ), + messageText := fmt.Sprintf(AdminDeclinedMsg, + userString, user.From.ID, user.JoinReason, adminUserString, + time.Now().Format("2006-01-02 15:04:05"), + defaultReason, ) + edit := api.NewEditMessageText(query.Message.Chat.ID, query.Message.MessageID, messageText) + edit.ParseMode = api.ModeHTML + edit.Entities = query.Message.Entities + + if len(bot.Config.CannedDeclineResponses) > 0 { + var rows [][]api.InlineKeyboardButton + for i, response := range bot.Config.CannedDeclineResponses { + // Clean up the response text for button display + snippet := strings.TrimSpace(response) + snippet = strings.ReplaceAll(snippet, "\n", " ") + // Remove multiple consecutive spaces + for strings.Contains(snippet, " ") { + snippet = strings.ReplaceAll(snippet, " ", " ") + } + // Truncate to 30 chars for button text + if len(snippet) > 30 { + snippet = snippet[:30] + "..." + } + btn := api.NewInlineKeyboardButtonData(snippet, fmt.Sprintf("cannedrespsel_%d_%d", user.From.ID, i)) + rows = append(rows, []api.InlineKeyboardButton{btn}) + } + keyboard := api.NewInlineKeyboardMarkup(rows...) + edit.ReplyMarkup = &keyboard + } + + bot.API.Send(edit) + bot.API.Request(api.NewCallback(query.ID, "Join request declined.")) } @@ -176,12 +229,14 @@ userID, username, joinReason, declinedBy, declinedAt := utils.GetInfoFromMsg(repliedMsg.Text) reason := utils.EscapeHTML(update.Message.Text) - if strings.HasPrefix(update.Message.Text, "+") { - reason = utils.EscapeHTML(update.Message.Text[1:]) + if !strings.HasPrefix(update.Message.Text, "/") { + reason = utils.EscapeHTML(update.Message.Text) utils.SendMessage(bot.API, userID, 0, fmt.Sprintf("Your join request was declined for the following reason:\n\n%s", reason)) } + reason = strings.TrimPrefix(reason, "/") + utils.EditMessage(bot.API, update.Message.Chat.ID, repliedMsg.MessageID, fmt.Sprintf(AdminDeclinedMsg, username, userID, joinReason, declinedBy, declinedAt, reason)) @@ -189,6 +244,26 @@ bot.API.Send(api.NewDeleteMessage(update.Message.Chat.ID, update.Message.MessageID)) } +// sendCannedResponse sends a canned decline response to the declined user. +func (bot *Bot) sendCannedResponse(query *api.CallbackQuery, user *ExtendedChatJoinRequest, respIdx int) { + if respIdx < 0 || respIdx >= len(bot.Config.CannedDeclineResponses) { + return + } + + reason := utils.EscapeHTML(bot.Config.CannedDeclineResponses[respIdx]) + utils.SendMessage(bot.API, user.From.ID, 0, + fmt.Sprintf("Your join request was declined for the following reason:\n\n%s", reason)) + + // Extract user info from original message and reformat with the canned response + userID, username, joinReason, declinedBy, declinedAt := utils.GetInfoFromMsg(query.Message.Text) + + edit := api.NewEditMessageText(query.Message.Chat.ID, query.Message.MessageID, + fmt.Sprintf(AdminDeclinedMsg, username, userID, joinReason, declinedBy, declinedAt, reason)) + edit.ParseMode = api.ModeHTML + edit.Entities = query.Message.Entities + bot.API.Send(edit) +} + // 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, "_"), " ") --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -13,7 +13,7 @@ AdminDeclinedMsg = "❌ Join #request declined for %s [%d]\n\nJoin reason: %s\nDeclined by: %s\nDeclined at: %s\nDeclined reason: %s" AdminBannedMsg = "🚫 Join #request banned for %s [%d]\n\nJoin reason: %s\nBanned by: %s\nBanned at: %s\nBanned until: %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)" + defaultReason = "(no reason provided, reply to this message to send one to the user, prepend with / to just set it)" ) // Types shared by handler files --- a/handlers/join.go +++ b/handlers/join.go @@ -19,6 +19,10 @@ userString := utils.BuildUserString(&user.From) keyboard := utils.NewApprovalKeyboard(user.From.ID) + if bot.Config.ReminderMessage != "" { + newButton := api.NewInlineKeyboardButtonData("Send Reminder", fmt.Sprintf("remind_%d", user.From.ID)) + keyboard.InlineKeyboard[1] = append([]api.InlineKeyboardButton{newButton}, keyboard.InlineKeyboard[1]...) + } utils.EditMessageWithKeyboard(bot.API, *bot.Config.AdminChatId, user.JoinRequestMessageID, fmt.Sprintf(AdminJoinRequestMsg, userString, user.From.ID, user.JoinReason), &keyboard) @@ -27,12 +31,24 @@ // HandleJoinRequest initiates the join approval flow by sending the entry message and admin notification. func (bot *Bot) HandleJoinRequest(request *api.ChatJoinRequest) { + // Check if user already has a pending request + if existingUser := bot.GetPendingUser(request.From.ID); existingUser != nil { + utils.SendMessage(bot.API, request.From.ID, 0, + "You have already requested to join. Please send a single message as your join reason.") + return + } + utils.SendMessage(bot.API, request.From.ID, 0, bot.Config.EntryMessage) userString := utils.BuildUserString(&request.From) + keyboard := utils.NewApprovalKeyboard(request.From.ID) + if bot.Config.ReminderMessage != "" { + newButton := api.NewInlineKeyboardButtonData("Send Reminder", fmt.Sprintf("remind_%d", request.From.ID)) + keyboard.InlineKeyboard[1] = append([]api.InlineKeyboardButton{newButton}, keyboard.InlineKeyboard[1]...) + } m := api.NewMessage(*bot.Config.AdminChatId, fmt.Sprintf(AdminJoinRequestMsg, userString, request.From.ID, "(awaiting user response)")) - m.ReplyMarkup = utils.NewApprovalKeyboard(request.From.ID) + m.ReplyMarkup = keyboard m.ParseMode = api.ModeHTML m.LinkPreviewOptions = api.LinkPreviewOptions{IsDisabled: true} if topic := *bot.Config.AdminChatTopicId; topic != 0 { --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -38,7 +38,7 @@ func NewApprovalKeyboard(userID int64) api.InlineKeyboardMarkup { approveBtn := api.NewInlineKeyboardButtonData("Approve", fmt.Sprintf("approve_%d", userID)) declineBtn := api.NewInlineKeyboardButtonData("Decline", fmt.Sprintf("decline_%d", userID)) - banBtn := api.NewInlineKeyboardButtonData("Ban (24h)", fmt.Sprintf("ban_%d", userID)) + banBtn := api.NewInlineKeyboardButtonData("⚠️ Ban (24h)", fmt.Sprintf("ban_%d", userID)) return api.NewInlineKeyboardMarkup( []api.InlineKeyboardButton{approveBtn, declineBtn}, []api.InlineKeyboardButton{banBtn},