diff --git a/patches/0002-nuzzles.patch b/patches/0002-nuzzles.patch
new file mode 100644
index 0000000..2c99613
--- /dev/null
+++ b/patches/0002-nuzzles.patch
@@ -0,0 +1,180 @@
+--- a/config/config.go
++++ b/config/config.go
+@@ -8,14 +8,15 @@
+ )
+
+ 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"`
++ SendApprovalMessage bool `yaml:"send_approval_message"`
++ DeleteRequestAfterDecision bool `yaml:"delete_request_after_decision"`
++ CannedDeclineResponses []string `yaml:"canned_decline_responses"`
+ }
+
+ func (c *Config) LoadConfig() error {
+@@ -47,6 +48,7 @@
+ ApprovalMessage: "",
+ SendApprovalMessage: false,
+ DeleteRequestAfterDecision: false,
++ CannedDeclineResponses: []string{},
+ }
+
+ encoder := yaml.NewEncoder(f)
+--- a/handlers/callbacks.go
++++ b/handlers/callbacks.go
+@@ -36,6 +36,7 @@
+ switch action {
+ case "approve":
+ bot.handleApproveRequest(query, user, userString, adminUserString)
++ bot.DeletePendingUser(args)
+ case "decline":
+ bot.handleDeclineRequest(query, user, userString, adminUserString)
+ case "ban":
+@@ -44,10 +45,19 @@
+ return
+ case "banc":
+ bot.handleBanRequest(query, user, userString, adminUserString)
++ bot.DeletePendingUser(args)
++ 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
+ }
+
+- bot.DeletePendingUser(args)
+-
+ if bot.Config.DeleteRequestAfterDecision {
+ go bot.scheduleMessageDeletion(query.Message.Chat.ID, query.Message.MessageID, 10*time.Second)
+ }
+@@ -93,14 +103,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."))
+ }
+
+@@ -175,12 +210,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))
+
+@@ -188,6 +225,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 2026-03-10
++++ b/handlers/handlers.go 2026-03-11
+
+@@ -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
+diff -ruN '--exclude=.git' '--exclude=.gitmodules' '--exclude=telegram-join-approval-bot' telegram-join-approval-bot/pkg/utils/utils.go telegram-join-approval-nuzzles/pkg/utils/utils.go
+--- a/pkg/utils/utils.go 2026-03-10
++++ b/pkg/utils/utils.go 2026-03-11
+@@ -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", 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},
diff --git a/patches/0003-nuzzles.patch b/patches/0003-nuzzles.patch
new file mode 100644
index 0000000..d40c0b7
--- /dev/null
+++ b/patches/0003-nuzzles.patch
@@ -0,0 +1,246 @@
+--- 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)aaa", fmt.Sprintf("ban_%d", userID))
+ return api.NewInlineKeyboardMarkup(
+ []api.InlineKeyboardButton{approveBtn, declineBtn},
+ []api.InlineKeyboardButton{banBtn},
diff --git a/patches/0004-callbacks.go.patch b/patches/0004-callbacks.go.patch
new file mode 100644
index 0000000..d71db9e
--- /dev/null
+++ b/patches/0004-callbacks.go.patch
@@ -0,0 +1,152 @@
+--- a/handlers/callbacks.go
++++ b//handlers/callbacks.go
+@@ -6,7 +6,7 @@
+ "strings"
+ "time"
+
+- utils "git.zio.sh/astra/telegram-join-approval-bot/pkg/utils"
++ utils "git.zio.sh/astra/telegram-join-approval-nuzzles/pkg/utils"
+ api "github.com/OvyFlash/telegram-bot-api"
+ )
+
+@@ -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, "_"), " ")
diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go
index c18a452..e0b4ebe 100644
--- a/pkg/utils/utils.go
+++ b/pkg/utils/utils.go
@@ -38,7 +38,7 @@ func EscapeHTML(s string) string {
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)aaa", fmt.Sprintf("ban_%d", userID))
return api.NewInlineKeyboardMarkup(
[]api.InlineKeyboardButton{approveBtn, declineBtn},
[]api.InlineKeyboardButton{banBtn},
diff --git a/scripts/sync.sh b/scripts/sync.sh
index ffcc7ae..77a31c2 100755
--- a/scripts/sync.sh
+++ b/scripts/sync.sh
@@ -15,7 +15,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SUBMODULE_DIR="$ROOT_DIR/telegram-join-approval-bot"
-PATCH_FILE="$ROOT_DIR/patches/0001-nuzzles.patch"
+PATCH_FILE="$ROOT_DIR/patches/0003-nuzzles.patch"
# ── 1. Ensure submodule is initialised ────────────────────────────────────────
echo "→ Updating submodule..."
diff --git a/telegram-join-approval-bot b/telegram-join-approval-bot
index 98e6a4a..f3d290d 160000
--- a/telegram-join-approval-bot
+++ b/telegram-join-approval-bot
@@ -1 +1 @@
-Subproject commit 98e6a4a100cb350cadc0a074894bb2fb0b0c9394
+Subproject commit f3d290d6d76cc03228d870bef9bdef7297e647f1
diff --git a/telegram-join-approval-nuzzles b/telegram-join-approval-nuzzles
new file mode 160000
index 0000000..334fe2b
--- /dev/null
+++ b/telegram-join-approval-nuzzles
@@ -0,0 +1 @@
+Subproject commit 334fe2bf8f84228ef0e0bfabb0fd4173a29ebb62