From 8e1963fd090444223f7f112d33482aafcbee63cd Mon Sep 17 00:00:00 2001 From: Nimsara Date: Wed, 25 Mar 2026 13:00:10 +0530 Subject: [PATCH] feat: implement standing order feature with creation, listing, and deletion commands --- .gitignore | 4 +- internal/lnbits/types.go | 12 + internal/telegram/bot.go | 3 + internal/telegram/database.go | 4 + internal/telegram/handler.go | 16 ++ internal/telegram/standing_order_scheduler.go | 127 +++++++++++ internal/telegram/standing_orders.go | 211 ++++++++++++++++++ 7 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 internal/telegram/standing_order_scheduler.go create mode 100644 internal/telegram/standing_orders.go diff --git a/.gitignore b/.gitignore index c965465a..2b02c3f0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ data/* LightningTipBot LightningTipBot.exe BitcoinDeepaBot -test_pay_api.sh \ No newline at end of file +test_pay_api.sh + +.DS_Store \ No newline at end of file diff --git a/internal/lnbits/types.go b/internal/lnbits/types.go index ae14ab60..b5d7fcaa 100644 --- a/internal/lnbits/types.go +++ b/internal/lnbits/types.go @@ -193,3 +193,15 @@ type SavingsPot struct { UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` User *User `gorm:"foreignKey:UserID;references:ID"` } + +type StandingOrder struct { + ID string `json:"id" gorm:"primaryKey"` + UserID string `json:"user_id" gorm:"index"` + PotName string `json:"pot_name"` + DayOfMonth int `json:"day_of_month"` + Amount int64 `json:"amount"` + Active bool `json:"active" gorm:"default:true"` + LastExecutedAt *time.Time `json:"last_executed_at"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 447ca68a..e7884a8d 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -126,6 +126,9 @@ func (bot *TipBot) Start() { // register telegram handlers bot.registerTelegramHandlers() + // start standing order scheduler + NewStandingOrderScheduler(bot).Start() + // download bot avatar once bot.downloadMyProfilePicture() diff --git a/internal/telegram/database.go b/internal/telegram/database.go index bc7f62a5..6dd7791b 100644 --- a/internal/telegram/database.go +++ b/internal/telegram/database.go @@ -121,6 +121,10 @@ func AutoMigration() *Databases { if err != nil { panic(err) } + err = orm.AutoMigrate(&lnbits.StandingOrder{}) + if err != nil { + panic(err) + } txLogger, err := gorm.Open(sqlite.Open(internal.Configuration.Database.TransactionsPath), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true, FullSaveAssociations: true}) if err != nil { diff --git a/internal/telegram/handler.go b/internal/telegram/handler.go index d8f0c98c..c2c08738 100644 --- a/internal/telegram/handler.go +++ b/internal/telegram/handler.go @@ -372,6 +372,22 @@ func (bot TipBot) getHandler() []InterceptionWrapper { }, }, }, + { + Endpoints: []interface{}{"/so"}, + Handler: bot.soHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, { Endpoints: []interface{}{"/send", &btnSendMenuEnter}, Handler: bot.sendHandler, diff --git a/internal/telegram/standing_order_scheduler.go b/internal/telegram/standing_order_scheduler.go new file mode 100644 index 00000000..c5cc3d0a --- /dev/null +++ b/internal/telegram/standing_order_scheduler.go @@ -0,0 +1,127 @@ +package telegram + +import ( + "fmt" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + log "github.com/sirupsen/logrus" +) + +// StandingOrderScheduler runs hourly and executes standing orders on the +// configured day of month, clamping days 29–31 to the last day of short months. +type StandingOrderScheduler struct { + bot *TipBot + CheckInterval time.Duration +} + +func NewStandingOrderScheduler(bot *TipBot) *StandingOrderScheduler { + return &StandingOrderScheduler{ + bot: bot, + CheckInterval: 1 * time.Hour, + } +} + +func (s *StandingOrderScheduler) Start() { + go s.run() +} + +func (s *StandingOrderScheduler) run() { + for { + s.processDueOrders() + time.Sleep(s.CheckInterval) + } +} + +// effectiveDayForMonth returns the day the order should fire in the given month. +// If the configured day exceeds the month's last day (e.g. day 31 in April), +// it clamps to the last day so salary-day users are never skipped. +func effectiveDayForMonth(configuredDay int, t time.Time) int { + // time.Date with day=0 of next month gives last day of current month + lastDay := time.Date(t.Year(), t.Month()+1, 0, 0, 0, 0, 0, t.Location()).Day() + if configuredDay > lastDay { + return lastDay + } + return configuredDay +} + +// shouldExecuteToday returns true if the order has not already run today. +// Protects against double-execution when the bot restarts mid-day. +func shouldExecuteToday(order lnbits.StandingOrder, now time.Time) bool { + if order.LastExecutedAt == nil { + return true + } + last := *order.LastExecutedAt + return last.Year() != now.Year() || last.Month() != now.Month() || last.Day() != now.Day() +} + +func (s *StandingOrderScheduler) processDueOrders() { + now := time.Now() + today := now.Day() + + var orders []lnbits.StandingOrder + if err := s.bot.DB.Users.Where("active = true").Find(&orders).Error; err != nil { + log.Errorf("[StandingOrderScheduler] Failed to fetch orders: %v", err) + return + } + + for _, order := range orders { + if effectiveDayForMonth(order.DayOfMonth, now) != today { + continue + } + if !shouldExecuteToday(order, now) { + continue + } + + // Load the user + var user lnbits.User + if err := s.bot.DB.Users.Where("id = ?", order.UserID).First(&user).Error; err != nil { + log.Errorf("[StandingOrderScheduler] User not found for order %s: %v", order.ID, err) + continue + } + + // Skip banned or wallet-less users silently + if user.Banned || user.Wallet == nil { + continue + } + + if err := s.executeOrder(&order, &user); err != nil { + s.notifyFailure(&user, order, err) + } else { + s.notifySuccess(&user, order) + } + } +} + +func (s *StandingOrderScheduler) executeOrder(order *lnbits.StandingOrder, user *lnbits.User) error { + if err := s.bot.TransferToPot(user, order.PotName, order.Amount); err != nil { + return err + } + + now := time.Now() + order.LastExecutedAt = &now + if err := s.bot.DB.Users.Save(order).Error; err != nil { + log.Errorf("[StandingOrderScheduler] Failed to update LastExecutedAt for order %s: %v", order.ID, err) + } + return nil +} + +func (s *StandingOrderScheduler) notifySuccess(user *lnbits.User, order lnbits.StandingOrder) { + log.Infof("[StandingOrderScheduler] Executed order %s for user %s: %s → pot '%s'", + order.ID, user.Name, utils.FormatSats(order.Amount), order.PotName) + msg := fmt.Sprintf( + "āœ… *Standing Order Executed*\n\nšŸ“… Day %d of month\nšŸ’° *%s* transferred to pot *'%s'*", + order.DayOfMonth, utils.FormatSats(order.Amount), order.PotName, + ) + s.bot.trySendMessage(user.Telegram, msg) +} + +func (s *StandingOrderScheduler) notifyFailure(user *lnbits.User, order lnbits.StandingOrder, err error) { + log.Errorf("[StandingOrderScheduler] Failed to execute order %s for user %s: %v", order.ID, user.Name, err) + msg := fmt.Sprintf( + "āš ļø *Standing Order Failed*\n\nšŸ“… Day %d of month\nšŸ’° %s → pot *'%s'*\n\n🚫 Reason: %s", + order.DayOfMonth, utils.FormatSats(order.Amount), order.PotName, err.Error(), + ) + s.bot.trySendMessage(user.Telegram, msg) +} diff --git a/internal/telegram/standing_orders.go b/internal/telegram/standing_orders.go new file mode 100644 index 00000000..0f69318a --- /dev/null +++ b/internal/telegram/standing_orders.go @@ -0,0 +1,211 @@ +package telegram + +import ( + "fmt" + "strconv" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + uuid "github.com/satori/go.uuid" +) + +const ( + MaxStandingOrdersPerUser = 10 + MinDayOfMonth = 1 + MaxDayOfMonth = 31 +) + +func (bot *TipBot) CreateStandingOrder(user *lnbits.User, dayOfMonth int, amount int64, potName string) (*lnbits.StandingOrder, error) { + if dayOfMonth < MinDayOfMonth || dayOfMonth > MaxDayOfMonth { + return nil, fmt.Errorf("day must be between %d and %d", MinDayOfMonth, MaxDayOfMonth) + } + if amount <= 0 { + return nil, fmt.Errorf("amount must be positive") + } + + potName = strings.TrimSpace(potName) + if _, err := bot.GetPot(user, potName); err != nil { + return nil, fmt.Errorf("pot '%s' not found — create it first with /createpot", potName) + } + + var orderCount int64 + bot.DB.Users.Model(&lnbits.StandingOrder{}).Where("user_id = ? AND active = true", user.ID).Count(&orderCount) + if orderCount >= MaxStandingOrdersPerUser { + return nil, fmt.Errorf("maximum number of standing orders reached (%d)", MaxStandingOrdersPerUser) + } + + order := &lnbits.StandingOrder{ + ID: uuid.NewV4().String(), + UserID: user.ID, + PotName: potName, + DayOfMonth: dayOfMonth, + Amount: amount, + Active: true, + } + + if err := bot.DB.Users.Create(order).Error; err != nil { + return nil, fmt.Errorf("failed to create standing order: %w", err) + } + + return order, nil +} + +func (bot *TipBot) ListStandingOrders(user *lnbits.User) ([]lnbits.StandingOrder, error) { + var orders []lnbits.StandingOrder + err := bot.DB.Users.Where("user_id = ? AND active = true", user.ID).Order("day_of_month ASC").Find(&orders).Error + return orders, err +} + +func (bot *TipBot) GetStandingOrderByID(user *lnbits.User, orderID string) (*lnbits.StandingOrder, error) { + var order lnbits.StandingOrder + err := bot.DB.Users.Where("id = ? AND user_id = ?", orderID, user.ID).First(&order).Error + if err != nil { + return nil, fmt.Errorf("standing order not found") + } + return &order, nil +} + +func (bot *TipBot) DeleteStandingOrder(user *lnbits.User, orderID string) error { + result := bot.DB.Users.Where("id = ? AND user_id = ?", orderID, user.ID).Delete(&lnbits.StandingOrder{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("standing order not found") + } + return nil +} + +// ─── Telegram Handler ───────────────────────────────────────────────────────── + +const soHelpText = "šŸ“… *Standing Orders (/so)*\n\n" + + "`/so create ` — create a standing order\n" + + "`/so list` — list your standing orders\n" + + "`/so delete ` — delete by list number\n\n" + + "*Example:* `/so create 25 1000 Savings`\n" + + "_Day 29–31 fires on the last day of shorter months._" + +// soHandler is the single entry point for all /so sub-commands. +func (bot *TipBot) soHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + arguments := strings.Fields(m.Text) + if len(arguments) < 2 { + bot.trySendMessage(ctx.Sender(), soHelpText) + return ctx, nil + } + + switch strings.ToLower(arguments[1]) { + case "create": + return bot.soCreateHandler(ctx, user, arguments) + case "list": + return bot.soListHandler(ctx, user) + case "delete": + return bot.soDeleteHandler(ctx, user, arguments) + default: + bot.trySendMessage(ctx.Sender(), soHelpText) + } + return ctx, nil +} + +func (bot *TipBot) soCreateHandler(ctx intercept.Context, user *lnbits.User, arguments []string) (intercept.Context, error) { + // /so create + if len(arguments) < 5 { + bot.trySendMessage(ctx.Sender(), "šŸ“… *Usage:* `/so create `\n\nExample: `/so create 25 1000 Savings`") + return ctx, nil + } + + dayOfMonth, err := strconv.Atoi(arguments[2]) + if err != nil { + bot.trySendMessage(ctx.Sender(), "āŒ Invalid day — must be a number between 1 and 31.") + return ctx, nil + } + + amount, err := getAmount(ctx, arguments[3]) + if err != nil { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("āŒ Invalid amount: %s", err.Error())) + return ctx, err + } + + potName := strings.Join(arguments[4:], " ") + + order, err := bot.CreateStandingOrder(user, dayOfMonth, amount, potName) + if err != nil { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("āŒ %s", err.Error())) + return ctx, err + } + + bot.trySendMessage(ctx.Sender(), fmt.Sprintf( + "āœ… *Standing Order Created*\n\nšŸ“… Day *%d* of each month\nšŸ’° *%s* → pot *'%s'*\n\nYour balance will be checked automatically on the scheduled day.", + order.DayOfMonth, utils.FormatSats(order.Amount), order.PotName, + )) + return ctx, nil +} + +func (bot *TipBot) soListHandler(ctx intercept.Context, user *lnbits.User) (intercept.Context, error) { + orders, err := bot.ListStandingOrders(user) + if err != nil { + bot.trySendMessage(ctx.Sender(), "āŒ Failed to fetch your standing orders.") + return ctx, err + } + + if len(orders) == 0 { + bot.trySendMessage(ctx.Sender(), "šŸ“… You have no standing orders yet.\n\nUse `/so create ` to create one.") + return ctx, nil + } + + message := "šŸ“… *Your Standing Orders:*\n\n" + for i, order := range orders { + lastRun := "never run" + if order.LastExecutedAt != nil { + lastRun = fmt.Sprintf("last run: %s", order.LastExecutedAt.Format("2006-01-02")) + } + message += fmt.Sprintf("%d. Day *%d* → *%s* to pot *'%s'* [%s]\n", + i+1, order.DayOfMonth, utils.FormatSats(order.Amount), order.PotName, lastRun) + } + message += "\nUse `/so delete ` to remove one." + + bot.trySendMessage(ctx.Sender(), message) + return ctx, nil +} + +func (bot *TipBot) soDeleteHandler(ctx intercept.Context, user *lnbits.User, arguments []string) (intercept.Context, error) { + if len(arguments) < 3 { + bot.trySendMessage(ctx.Sender(), "šŸ“… *Usage:* `/so delete `\n\nUse `/so list` to see your list.") + return ctx, nil + } + + index, err := strconv.Atoi(arguments[2]) + if err != nil || index < 1 { + bot.trySendMessage(ctx.Sender(), "āŒ Invalid number. Use `/so list` to see the list numbers.") + return ctx, nil + } + + orders, err := bot.ListStandingOrders(user) + if err != nil { + bot.trySendMessage(ctx.Sender(), "āŒ Failed to fetch your standing orders.") + return ctx, err + } + + if index > len(orders) { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("āŒ No standing order at position %d. You have %d order(s).", index, len(orders))) + return ctx, nil + } + + order := orders[index-1] + if err := bot.DeleteStandingOrder(user, order.ID); err != nil { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("āŒ Failed to delete: %s", err.Error())) + return ctx, err + } + + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("šŸ—‘ļø Deleted: Day %d → %s to pot '%s'", order.DayOfMonth, utils.FormatSats(order.Amount), order.PotName)) + return ctx, nil +}