|
1 | 1 | import calendar |
| 2 | +import io |
2 | 3 | import logging |
3 | 4 | import typing as t |
4 | 5 | from datetime import datetime, timezone |
|
7 | 8 | from discord import app_commands |
8 | 9 | from redbot.core import Config, bank, commands |
9 | 10 | from redbot.core.i18n import Translator, cog_i18n |
| 11 | +from redbot.core.utils.chat_formatting import humanize_number, humanize_timedelta |
10 | 12 |
|
11 | 13 | from ..abc import MixinMeta |
12 | 14 | from ..common.models import CommandCost |
@@ -548,3 +550,242 @@ async def bank_set_role(self, ctx: commands.Context, role: discord.Role, creds: |
548 | 550 | f"{users_affected} {grammar}", f"{total} {currency}" |
549 | 551 | ) |
550 | 552 | await ctx.send(msg) |
| 553 | + |
| 554 | + @commands.command(name="backpay") |
| 555 | + @bank.is_owner_if_bank_global() |
| 556 | + @commands.admin_or_permissions(manage_guild=True) |
| 557 | + @commands.guild_only() |
| 558 | + async def backpay_cmd(self, ctx: commands.Context, duration: commands.TimedeltaConverter, confirm: bool = False): |
| 559 | + """Calculate and award missed paydays for all members within a time period. |
| 560 | +
|
| 561 | + This will calculate how many paydays each member could have claimed within the |
| 562 | + specified time period and award them accordingly. |
| 563 | +
|
| 564 | + By default, this command will only show a preview. Use `confirm=True` to apply changes. |
| 565 | +
|
| 566 | + Examples: |
| 567 | + - `[p]backpay 48h` - Preview paydays missed in the last 48 hours |
| 568 | + - `[p]backpay 7d True` - Calculate and give paydays missed in the last 7 days |
| 569 | +
|
| 570 | + **Arguments** |
| 571 | + - `<duration>` How far back to check for missed paydays. Use time abbreviations like 1d, 12h, etc. |
| 572 | + - `[confirm]` Set to True to actually apply the changes. Default: False (preview only) |
| 573 | + """ |
| 574 | + if await bank.is_global() and ctx.author.id not in self.bot.owner_ids: |
| 575 | + return await ctx.send(_("You must be a bot owner to use this command while global bank is active.")) |
| 576 | + |
| 577 | + if duration.total_seconds() <= 0: |
| 578 | + return await ctx.send(_("Duration must be positive!")) |
| 579 | + |
| 580 | + async with ctx.typing(): |
| 581 | + eco_cog = self.bot.get_cog("Economy") |
| 582 | + if not eco_cog: |
| 583 | + return await ctx.send(_("Economy cog is not loaded.")) |
| 584 | + |
| 585 | + eco_conf: Config = eco_cog.config |
| 586 | + is_global = await bank.is_global() |
| 587 | + currency = await bank.get_currency_name(ctx.guild) |
| 588 | + max_bal = await bank.get_max_balance(ctx.guild) |
| 589 | + |
| 590 | + # Get current time and the time to look back to |
| 591 | + current_time = calendar.timegm(datetime.now(tz=timezone.utc).utctimetuple()) |
| 592 | + lookback_time = current_time - int(duration.total_seconds()) |
| 593 | + |
| 594 | + # Create a list to store the report data |
| 595 | + report_data = [] |
| 596 | + users_updated = 0 |
| 597 | + total_credits = 0 |
| 598 | + |
| 599 | + if is_global: |
| 600 | + bankgroup = bank._config._get_base_group(bank._config.USER) |
| 601 | + ecogroup = eco_conf._get_base_group(eco_conf.USER) |
| 602 | + accounts: t.Dict[str, dict] = await bankgroup.all() |
| 603 | + ecousers: t.Dict[str, dict] = await ecogroup.all() |
| 604 | + payday_time = await eco_conf.PAYDAY_TIME() |
| 605 | + payday_credits = await eco_conf.PAYDAY_CREDITS() |
| 606 | + |
| 607 | + for member in ctx.guild.members: |
| 608 | + uid = str(member.id) |
| 609 | + |
| 610 | + # Skip users with no bank accounts or economy data |
| 611 | + if uid not in accounts or uid not in ecousers: |
| 612 | + continue |
| 613 | + |
| 614 | + # Get their last payday time |
| 615 | + last_payday = ecousers[uid].get("next_payday", 0) |
| 616 | + |
| 617 | + # If their last payday is after our lookback time, skip |
| 618 | + if last_payday > lookback_time: |
| 619 | + continue |
| 620 | + |
| 621 | + # Calculate how many paydays they could have claimed |
| 622 | + potential_paydays = (current_time - last_payday) // payday_time |
| 623 | + if potential_paydays <= 0: |
| 624 | + continue |
| 625 | + |
| 626 | + # Calculate amount to give (base amount * number of paydays) |
| 627 | + amount_to_give = payday_credits * potential_paydays |
| 628 | + |
| 629 | + # Don't exceed max balance |
| 630 | + current_balance = accounts[uid]["balance"] |
| 631 | + new_balance = min(current_balance + amount_to_give, max_bal) |
| 632 | + added_amount = new_balance - current_balance |
| 633 | + |
| 634 | + # Add to report even if no credits are added due to max balance |
| 635 | + report_data.append( |
| 636 | + { |
| 637 | + "name": member.display_name, |
| 638 | + "id": member.id, |
| 639 | + "paydays": potential_paydays, |
| 640 | + "amount": added_amount, |
| 641 | + "current_balance": current_balance, |
| 642 | + "new_balance": new_balance, |
| 643 | + "max_hit": added_amount < amount_to_give, |
| 644 | + } |
| 645 | + ) |
| 646 | + |
| 647 | + # Track stats |
| 648 | + total_credits += added_amount |
| 649 | + users_updated += 1 |
| 650 | + |
| 651 | + # Only update the account if confirm is True |
| 652 | + if confirm: |
| 653 | + accounts[uid]["balance"] = new_balance |
| 654 | + ecousers[uid]["next_payday"] = current_time |
| 655 | + |
| 656 | + # Save changes if any and confirmation is True |
| 657 | + if users_updated > 0 and confirm: |
| 658 | + await bankgroup.set(accounts) |
| 659 | + await ecogroup.set(ecousers) |
| 660 | + |
| 661 | + else: |
| 662 | + # Per-guild logic |
| 663 | + conf = self.db.get_conf(ctx.guild) |
| 664 | + bankgroup = bank._config._get_base_group(bank._config.MEMBER, str(ctx.guild.id)) |
| 665 | + ecogroup = eco_conf._get_base_group(eco_conf.MEMBER, str(ctx.guild.id)) |
| 666 | + accounts: t.Dict[str, dict] = await bankgroup.all() |
| 667 | + ecousers: t.Dict[str, dict] = await ecogroup.all() |
| 668 | + payday_time = await eco_conf.guild(ctx.guild).PAYDAY_TIME() |
| 669 | + payday_credits = await eco_conf.guild(ctx.guild).PAYDAY_CREDITS() |
| 670 | + payday_roles: t.Dict[int, dict] = await eco_conf.all_roles() |
| 671 | + |
| 672 | + for member in ctx.guild.members: |
| 673 | + uid = str(member.id) |
| 674 | + |
| 675 | + # Skip users with no bank accounts or economy data |
| 676 | + if uid not in accounts or uid not in ecousers: |
| 677 | + continue |
| 678 | + |
| 679 | + # Get their last payday time |
| 680 | + last_payday = ecousers[uid].get("next_payday", 0) |
| 681 | + |
| 682 | + # If their last payday is after our lookback time, skip |
| 683 | + if last_payday > lookback_time: |
| 684 | + continue |
| 685 | + |
| 686 | + # Calculate how many paydays they could have claimed |
| 687 | + potential_paydays = (current_time - last_payday) // payday_time |
| 688 | + if potential_paydays <= 0: |
| 689 | + continue |
| 690 | + |
| 691 | + # Calculate per-payday amount with role bonuses |
| 692 | + base_amount = payday_credits |
| 693 | + for role in member.roles: |
| 694 | + if role.id in payday_roles: |
| 695 | + role_credits = payday_roles[role.id]["PAYDAY_CREDITS"] |
| 696 | + if conf.stack_paydays: |
| 697 | + base_amount += role_credits |
| 698 | + elif role_credits > base_amount: |
| 699 | + base_amount = role_credits |
| 700 | + |
| 701 | + # Apply role bonus multipliers if configured |
| 702 | + if conf.role_bonuses and any(role.id in conf.role_bonuses for role in member.roles): |
| 703 | + highest_bonus = max(conf.role_bonuses.get(role.id, 0) for role in member.roles) |
| 704 | + base_amount += round(base_amount * highest_bonus) |
| 705 | + |
| 706 | + # Calculate total amount to give |
| 707 | + amount_to_give = base_amount * potential_paydays |
| 708 | + |
| 709 | + # Don't exceed max balance |
| 710 | + current_balance = accounts[uid]["balance"] |
| 711 | + new_balance = min(current_balance + amount_to_give, max_bal) |
| 712 | + added_amount = new_balance - current_balance |
| 713 | + |
| 714 | + # Add to report even if no credits are added due to max balance |
| 715 | + report_data.append( |
| 716 | + { |
| 717 | + "name": member.display_name, |
| 718 | + "id": member.id, |
| 719 | + "paydays": potential_paydays, |
| 720 | + "amount": added_amount, |
| 721 | + "current_balance": current_balance, |
| 722 | + "new_balance": new_balance, |
| 723 | + "max_hit": added_amount < amount_to_give, |
| 724 | + "payday_value": base_amount, |
| 725 | + } |
| 726 | + ) |
| 727 | + |
| 728 | + # Track stats |
| 729 | + total_credits += added_amount |
| 730 | + users_updated += 1 |
| 731 | + |
| 732 | + # Only update the account if confirm is True |
| 733 | + if confirm: |
| 734 | + accounts[uid]["balance"] = new_balance |
| 735 | + ecousers[uid]["next_payday"] = current_time |
| 736 | + |
| 737 | + # Save changes if any and confirmation is True |
| 738 | + if users_updated > 0 and confirm: |
| 739 | + await bankgroup.set(accounts) |
| 740 | + await ecogroup.set(ecousers) |
| 741 | + |
| 742 | + # Generate report |
| 743 | + if users_updated > 0: |
| 744 | + # Sort by amount in descending order |
| 745 | + report_data.sort(key=lambda x: x["amount"], reverse=True) |
| 746 | + |
| 747 | + report_lines = [ |
| 748 | + f"# Backpay Report for {humanize_timedelta(seconds=int(duration.total_seconds()))}\n", |
| 749 | + f"Total Users: {users_updated}", |
| 750 | + f"Total Credits: {humanize_number(total_credits)} {currency}\n", |
| 751 | + "Details:", |
| 752 | + ] |
| 753 | + |
| 754 | + for entry in report_data: |
| 755 | + max_note = " (Max balance hit)" if entry.get("max_hit") else "" |
| 756 | + per_payday = ( |
| 757 | + f" ({humanize_number(entry.get('payday_value', payday_credits))}/payday)" |
| 758 | + if "payday_value" in entry |
| 759 | + else "" |
| 760 | + ) |
| 761 | + report_lines.append( |
| 762 | + f"{entry['name']} (ID: {entry['id']}): " |
| 763 | + f"{humanize_number(entry['amount'])} {currency} for {entry['paydays']} paydays{per_payday}{max_note}" |
| 764 | + ) |
| 765 | + |
| 766 | + report_text = "\n".join(report_lines) |
| 767 | + report_file = discord.File(io.StringIO(report_text), filename=f"backpay_report_{ctx.guild.id}.txt") |
| 768 | + |
| 769 | + if confirm: |
| 770 | + msg = _( |
| 771 | + "Backpay complete for {duration}!\n{users} users received a total of {credits} {currency}." |
| 772 | + ).format( |
| 773 | + duration=humanize_timedelta(seconds=int(duration.total_seconds())), |
| 774 | + users=humanize_number(users_updated), |
| 775 | + credits=humanize_number(total_credits), |
| 776 | + currency=currency, |
| 777 | + ) |
| 778 | + await ctx.send(msg, file=report_file) |
| 779 | + else: |
| 780 | + msg = _( |
| 781 | + "Backpay preview for {duration}:\n{users} users would receive a total of {credits} {currency}.\n" |
| 782 | + "Run with `confirm=True` to apply these changes." |
| 783 | + ).format( |
| 784 | + duration=humanize_timedelta(seconds=int(duration.total_seconds())), |
| 785 | + users=humanize_number(users_updated), |
| 786 | + credits=humanize_number(total_credits), |
| 787 | + currency=currency, |
| 788 | + ) |
| 789 | + await ctx.send(msg, file=report_file) |
| 790 | + else: |
| 791 | + await ctx.send(_("No users were eligible for backpay in that time period.")) |
0 commit comments