forked from BlackPeter13/LightningTipBot
-
Notifications
You must be signed in to change notification settings - Fork 1
feat: implement standing order feature with creation, listing, and deletion commands #56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,4 +5,6 @@ data/* | |
| LightningTipBot | ||
| LightningTipBot.exe | ||
| BitcoinDeepaBot | ||
| test_pay_api.sh | ||
| test_pay_api.sh | ||
|
|
||
| .DS_Store | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 92
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 479
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 1374
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 401
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 49
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 49
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 1072
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 49
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 2439
🏁 Script executed:
# Look at the full standing_order_scheduler.go to understand the context wc -l internal/telegram/standing_order_scheduler.goRepository: CeyLabs/BitcoinDeepaBot
Length of output: 116
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 3243
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 49
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 204
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 1924
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 1192
🏁 Script executed:
Repository: CeyLabs/BitcoinDeepaBot
Length of output: 132
Wrap transfer and execution state in a single transaction.
The transfer completes successfully, but if
Save(order)fails, theLastExecutedAttimestamp is never persisted—only logged. SinceprocessDueOrders()reads fresh orders from the database each hour andshouldExecuteToday()checks the databaseLastExecutedAt, a failed save causes the same order to re-execute on the next hourly check, triggering a duplicate transfer. Additionally, GORM'sSave()will INSERT a new record if the primary key is missing; if the order is concurrently deleted,Save()can recreate it. Both the transfer and timestamp update must be atomic, either within a transaction wrapping both operations or via an UPDATE-only approach that fails atomically.🤖 Prompt for AI Agents