├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── config-template.yml ├── docker-compose.yml ├── main.py ├── requirements.txt ├── shell.nix └── test.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .vscode 3 | **/__pycache__ 4 | venv -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | log.txt 2 | registry.dat 3 | config.yml 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-alpine 2 | WORKDIR /app 3 | RUN apk update && apk add --no-cache git gcc musl-dev 4 | COPY requirements.txt . 5 | RUN pip install -r requirements.txt 6 | CMD ["python", "main.py"] 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 cromachina 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ⚠️Due to unavoidable Cloudflare issues caused by directly using the Fanbox web API, and Fanbox soon releasing their own Discord integration which practically serves the same purpose as this bot, this repository will be archived. 2 | 3 | # Fanbox Discord Bot 4 | This bot is used to automate access control for my Fanbox Discord server. 5 | 6 | The bot accesses the Fanbox API using your Fanbox session token. This is found in your browser cookies when accessing Fanbox. 7 | 8 | ## Fanbox API Restriction 9 | As of 2024-06-26, it seems that Fanbox has increased security for their API, possibly to stop scrapers (by using Cloudflare). You may find that you have had to pass a captcha on Fanbox recently, and if you were using the bot before, it's now broken. Changes to the cookies the API uses seem to be tied to your IP address, so using the bot from another IP address will cause Fanbox API to return "403 Forbidden". 10 | 11 | If you want to run the bot on an always-online VM, you can get the correct tokens by using your VM as a proxy for your web browser like so: 12 | - Create an SSH tunnel to your VM server from the command line: `ssh -N -D 9090 myuser@my.server.ip.address` (replace `myuser` with your VM user name and `my.server.ip.address` with your VM's IP address). 13 | - On Windows, SSH might be installed by default, but if not, you can install PuTTY to make it available. 14 | - Go into your browser proxy settings, for example in Firefox: `Settings -> Network Settings -> Manual Proxy configuration` 15 | - Fill out `SOCKS Host` with `localhost` and `Port` with `9090`, and click `Ok` 16 | - Open a private tab, go to Fanbox and login. 17 | - Collect the cookies and headers needed by `config.yml` (see below under `Install and configuration`) 18 | - Important cookies: `cf_clearance`, `FANBOXSESSID` 19 | - Important headers: `user-agent` 20 | - If you update the browser that you retrieved the `user-agent` from, you'll likely have to update this again too! 21 | - Close the private tab and revert your browser network settings (usually `Use System Proxy Settings`) 22 | - You can stop the SSH tunnel by pressing `ctrl + C` 23 | 24 | ## Access control 25 | The Discord user sends the bot their Pixiv ID number, which will grant appropriate access. You can simply tell the users to message their Pixiv profile link to the bot, for example `https://www.pixiv.net/users/11`, which will extract `11` and check that ID. 26 | 27 | The `only_check*` flags are mutually exclusive. Only use one at a time, or none at all. 28 | 29 | When `only_check_highest_txn` is `True`, the highest transactions in a month that the user has ever had will be used to grant a role. 30 | 31 | When `only_check_current_sub` is `False`, the user's Pixiv ID is checked against their Fanbox transaction records. More details below in Auto Role Update. 32 | 33 | When `only_check_current_sub` is `True`, then the user's current subscription status is checked instead of their transaction records. 34 | 35 | When `only_check_recent_txns` is `True`, then transactions are only checked in the current month (plus some additional checks for the start of the month). 36 | 37 | When `strict_access` is `True`, the bot will disallow different Discord users from using the same Pixiv ID. When a user successfully authenticates, their Discord ID is "bound" to their Pixiv ID. Successfully authenticating again will update their Pixiv ID binding. The user can only be unbound by an admin command. Some users may have had to create new Discord accounts, therefore the you will have to manually resolve unbinding of their old account. See Admin commands below. 38 | 39 | ## Other functionality 40 | 41 | ### Auto Purge 42 | The bot can be configured to periodically purge old users without roles. See `cleanup` in the config. 43 | 44 | ### Auto Role Update 45 | The bot can be configured to periodically update a user's role based on their Fanbox subscription. See `auto_role_update` in the config. 46 | 47 | When `only_check_current_sub` is `False`, the subscription is checked whenever the bot thinks the subscription is going to change based on a user's previous transactions. The behavior of this is for "fair access", meaning that if a user pays for a month of time, then they get a month of access from that payment date, roughly. 48 | 49 | When `only_check_current_sub` is `True`, a previously registered user will have their roll updated based on their current subscription status at the time of the check. Transactions are not considered in this case. The behavior of this is like "unfair access", meaning that a user that subscribes only at the end of a month may not retain access into the next month. This behavior is similar to how Fanbox works. 50 | 51 | If the user wants their role to be updated immediately (such as to a higher role), then they can submit their Pixiv ID to the bot again to force a check. 52 | 53 | #### Period of role assignment by transactions 54 | The bot will make the best effort to assign the correct role based on the user's previous recent transactions, as well as ensure that they get to retain the role for the contiguous overflow days since making those transactions. For example: If a user had subscribed on 6/15, 7/1, and 8/1, then the last day of their subscription is approximately 9/15. 55 | 56 | #### Determining role assignment by transactions 57 | The highest role assigned is determined like so: A calendar month's transactions for a user are summed up and replaced by the last transaction in that month, so if they had two 500 yen transactions on 6/10 and 6/15, then this would be represented by one 1000 yen transaction on 6/15. Then, for contiguous months of transactions, the days in that period of time are filled starting with the highest roles first, for a month worth of time, in the positions each transaction starts, or the next available position that can be filled. This is easier to demonstrate with the following graphs: 58 | 59 | ![image](https://github.com/cromachina/fanbox-bot/assets/82557197/8e1e4414-5bdb-42cc-a1f9-f4d6e693e509) 60 | 61 | #### Why transactions? 62 | Transactions are used to determine roles because this is the only historical information that the Fanbox API provides. Unfortunately Fanbox does not provide what specific plan was purchased with a given transaction, which makes determining which role to assign more complicated. This also means that plans should be uniquely determined by their price. 63 | 64 | #### Adding or removing plans from Fanbox 65 | Each time the bot starts, plans are retrieved from Fanbox and cached. If you removed a plan from your Fanbox, you should still keep the plan in your `plan_roles` setting so that a user can still be granted the last valid role that plan represented. When no more users have that role, you could then remove that plan from the `plan_roles` setting without impacting user experience. 66 | 67 | When `only_check_current_sub` is `True`, a user who was subscribed to a removed plan might lose access. This is hard to test for. 68 | 69 | ## Admin commands 70 | Admin commands are prefixed with `!`, for example `!reset` 71 | - `add-user PIXIV_ID DISCORD_ID` attempt to grant access for another user. `DISCORD_ID` is the numerical ID of a user, not their user name. This command ignores `strict_access`. 72 | - `unbind-user-by-discord-id DISCORD_ID` remove a user's Pixiv ID binding and roles. 73 | - `unbind-user-by-pixiv-id PIXIV_ID` unbind all users sharing the same Pixiv ID. 74 | - `get-by-discord-id DISCORD_ID` get the Pixiv ID bound to the given user. 75 | - `get-by-pixiv-id PIXIV_ID` get all users using the same Pixiv ID. 76 | - `reset` removes all roles in your config from all users. Any other roles will be ignored. Unbinds all users. 77 | - `purge` manually runs the user purge. Any user with no roles will be kicked from the server. 78 | - `test-id PIXIV_ID` tests if a pixiv ID can obtain a role at this moment in time. I use this for debugging. 79 | - `export-csv` generates and sends you a CSV file containing user Discord IDs, Pixiv IDs and join dates. 80 | 81 | ## Install and configuration 82 | - Create a Discord app and bot: 83 | - https://discordpy.readthedocs.io/en/stable/discord.html 84 | - Additional steps: Go to your bot application settings, under the `Bot` tab, scroll down and enable the following settings: 85 | - `Server Members Intent` 86 | - `Message Content Intent` 87 | - ⚠ It is easiest to invite your bot instance to your server with administrator permissions to prevent permission errors. You can try using more restrictive permissions, but you will probably run into issues. 88 | - The bot's role must be higher in the role settings than the roles of the users it is assigning new roles to, otherwise you may get a permission error when assigning roles. 89 | - ⚠ Only invite one instance of a running bot to one server. If you invite the bot instance to multiple servers, it will only work with the first server it can find, which might be randomly ordered. 90 | - If you need a bot to run in multiple servers, then run different instances of the bot out of different directories, with different bot tokens (you have to create a new Discord app). 91 | - The bot must be running continually to service random requests and run periodic functions. If you do not have a continually running computer, then I recommend renting a lightweight VM on a cloud service (Google Cloud, AWS, DigitalOcean, etc.) to host your bot instance. When you get to updating the bot config, refer to `Fanbox API Restriction` above for how to retrieve the correct tokens for your VM. 92 | - I recommend installing Docker to run the bot, to both mitigate build issues and have your bot start automatically if your computer or VM restarts. 93 | - For Windows: https://www.docker.com/products/docker-desktop/ 94 | - If using a cloud VM, typically Debian or Ubuntu Linux: run `sudo apt update && sudo apt install docker` 95 | - Download (or clone) and extract this repository to a new directory. 96 | - Copy `config-template.yml` to `config.yml` 97 | - In `config.yml`, update all of the places with angle brackets: `<...>` 98 | - For example: `` becomes `12345`, but not `<12345>` (remove the brackets). 99 | - If you are not using a particular feature, you can fill it in with a dummy value, like `0`. 100 | - You can change any other default fields in `config.yml` as well to turn on other functionality. 101 | - To start the bot, run `docker compose up -d` in the bot directory. 102 | - To stop, run `docker compose down` in the bot directory. 103 | - Logs are written to `log.txt`, or you can view output with Docker `docker compose logs --follow` 104 | 105 | ## Updating the bot 106 | - Stop the bot `docker compose down` 107 | - Download the latest version of the bot 108 | - Run `docker compose build` to update dependencies 109 | - Start the bot `docker compose up -d` 110 | -------------------------------------------------------------------------------- /config-template.yml: -------------------------------------------------------------------------------- 1 | # Bot access token for discord. 2 | # [SECURITY] These bot tokens should be treated like secrets as they allow the bot program to connect to Discord servers. 3 | discord_token: 4 | 5 | # This is for testing out commands with another bot, optional. 6 | operator_token: 7 | operator_mode: False 8 | 9 | # ID of the role to use admin commands. 10 | # This must be a number, like 12345. 11 | # To get your Discord Role ID, turn on developer mode in Discord, right click on your role, and select "Copy ID". 12 | admin_role_id: 13 | 14 | # File to log system information to 15 | log_file: log.txt 16 | 17 | # Number of seconds to wait between processing a user's message. Spam protection 18 | rate_limit: 60 19 | 20 | # Add plans IDs and their associated role IDs here. 21 | # The plan ID number on the left hand side must be a string, like '12345', and not 12345 22 | # To get your plan ID, go to https://www.fanbox.cc/manage/plans, then click edit on the plan. 23 | # The plan ID will be in the address bar. Replace '12345' with your own plan ID. 24 | # must be a number, same as above. 25 | # To get the role ID, turn on developer mode in Discord, go to your server settings, then Roles, 26 | # then right click on the role and select "Copy Role ID" 27 | plan_roles: 28 | '12345': 29 | 30 | # Disallow multiple users from using the same Pixiv ID. 31 | strict_access: False 32 | 33 | ## The only_check* flags below are mutually exclusive. Only set one of them to True. 34 | 35 | # Check for the highest transaction ever to assign a role. 36 | # This mode will not work with auto_role_update. 37 | only_check_highest_txn: False 38 | 39 | # Check if the user is simply subscribed to a plan or not at this moment instead of using transaction records. 40 | # This is "less fair" access than the transactions method, and the user must be subscribed when submitting for access. 41 | only_check_current_sub: False 42 | 43 | # Check transactions only from the current month (and the previous month if the current date is within `leeway_days` 44 | # of the beginning of the current month). This only applies when `only_check_current_sub` is False. 45 | only_check_recent_txns: False 46 | 47 | # Periodic cleanup routines. Only runs after user activity 48 | cleanup: 49 | # If we should even run cleanup routines at all 50 | run: False 51 | # Run only if it has been X hours since the last run 52 | period_hours: 24 53 | # Purge no-roll members older than X hours 54 | member_age_hours: 24 55 | 56 | # Automatically update a user's role when it seems like their role will change. 57 | auto_role_update: 58 | run: False 59 | period_hours: 24 60 | # Number of days to extend the stop date for a derole. Can help for the possible 61 | # lapse in Fanbox transactions at the beginning of the month. 62 | leeway_days: 5 63 | 64 | # Messages to return to the user for each condition 65 | system_messages: 66 | rate_limited: "Rate limited, please wait {rate_limit} seconds. 67 | レートが制限されていますので、{rate_limit}秒お待ちください。" 68 | no_id_found: "Cannot detect a user ID in your message. 69 | メッセージ内のユーザーIDを検出できません。" 70 | access_denied: "Access denied for ID {id}.\nID{id}に対してアクセスが拒否されました。" 71 | id_bound: "Access denied. ID {id} is already bound to another user. Please contact the admin for assistance. 72 | アクセスが拒否されました。ID{id}はすでに別のユーザーにバインドされています。管理者にお問い合わせください。" 73 | access_granted: "Access granted. Please check the server for new channels! 74 | アクセスが許可されました。新しいチャンネルがないか、サーバーをチェックしてみてください!" 75 | system_error: "An error has occurred! The admin has been notified to fix it. 76 | エラーが発生しました!管理者が修正するように通知されています。" 77 | 78 | # Update these with cookies from your FANBOX page. These are needed to contact the FANBOX API. 79 | # To access your cookies with Chrome: Go to your FANBOX page -> Ctrl+Shift+J -> Application -> Cookies -> https://www.fanbox.cc 80 | # All values filled in must be strings, so if it's a number, you must 'quote' it, like '12345'. 81 | # [SECURITY] The values here should be treated like secrets, because they allow the bot to act on your behalf on FANBOX. 82 | # You may need to pass a captcha in your browser before updating these! 83 | session_cookies: 84 | cf_clearance: 85 | FANBOXSESSID: 86 | p_ab_d_id: 87 | p_ab_id_2: 88 | p_ab_id: 89 | privacy_policy_agreement: '7' 90 | privacy_policy_notification: '0' 91 | agreement_master_terms_of_use_202408: '1' 92 | 93 | # Needed to contact the FANBOX API. 94 | session_headers: 95 | accept: application/json, text/plain, */* 96 | accept-language: en-US,en;q=0.5 97 | sec-fetch-dest: empty 98 | sec-fetch-mode: cors 99 | sec-fetch-site: same-site 100 | TE: trailers 101 | referer: https://www.fanbox.cc/ 102 | origin: https://www.fanbox.cc 103 | # Get this from the network tab in your browser. Pick a random request and look at the headers tab. `User-Agent` should be near the bottom. 104 | # This will need to be updated every time your browser is updated. 105 | user-agent: 106 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | fanbox-bot: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | image: fanbox-bot 7 | restart: always 8 | volumes: 9 | - ./:/app 10 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import calendar 3 | import csv 4 | import datetime 5 | import io 6 | import itertools 7 | import json 8 | import logging 9 | import concurrent.futures 10 | import re 11 | import time 12 | 13 | import aiosqlite 14 | import discord 15 | import httpx 16 | import httpx_caching 17 | import yaml 18 | from discord.ext import commands 19 | 20 | config_file = 'config.yml' 21 | registry_db = 'registry.db' 22 | fanbox_id_prog = re.compile(r'(\d+)') 23 | periodic_tasks = {} 24 | 25 | class obj: 26 | def __init__(self, d): 27 | for k, v in d.items(): 28 | setattr(self, k, v) 29 | 30 | async def periodic(func, timeout): 31 | while True: 32 | try: 33 | await asyncio.wait_for(func(), timeout=timeout) 34 | except asyncio.TimeoutError as ex: 35 | logging.exception(ex) 36 | continue 37 | except AuthException: 38 | raise 39 | except Exception as ex: 40 | logging.exception(ex) 41 | await asyncio.sleep(timeout) 42 | 43 | class RateLimiter: 44 | def __init__(self, rate_limit_seconds): 45 | self.limit_lock = asyncio.Lock() 46 | self.rate_limit = rate_limit_seconds 47 | self.last_time = time.time() - self.rate_limit 48 | 49 | async def limit(self, task): 50 | async with self.limit_lock: 51 | await asyncio.sleep(max(self.last_time - time.time() + self.rate_limit, 0)) 52 | result = await task 53 | self.last_time = time.time() 54 | return result 55 | 56 | class AuthException(Exception): 57 | pass 58 | 59 | class FanboxClient: 60 | def __init__(self, cookies, headers): 61 | self.rate_limiter = RateLimiter(5) 62 | self.self_id = cookies['FANBOXSESSID'].split('_')[0] 63 | self.client = httpx.AsyncClient(base_url='https://api.fanbox.cc/', cookies=cookies, headers=headers) 64 | self.client = httpx_caching.CachingClient(self.client) 65 | 66 | async def get_payload(self, request, ok_404=False): 67 | response = await self.rate_limiter.limit(request) 68 | if response.status_code in [401, 403]: 69 | raise AuthException(f'Fanbox API reports {response.status_code} {response.reason_phrase}. session_cookies and headers in the config file has likely been invalidated and need to be updated. Restart the bot after updating.') 70 | if response.status_code == 404 and ok_404: 71 | return None 72 | response.raise_for_status() 73 | return json.loads(response.text)['body'] 74 | 75 | async def get_user(self, user_id): 76 | return await self.get_payload(self.client.get('legacy/manage/supporter/user', params={'userId': user_id}), ok_404=True) 77 | 78 | async def get_plans(self): 79 | return await self.get_payload(self.client.get('plan.listCreator', params={'userId': self.self_id})) 80 | 81 | async def get_all_users(self): 82 | return await self.get_payload(self.client.get('relationship.listFans', params={'status': 'supporter'})) 83 | 84 | def map_dict(a, f): 85 | return dict(f(*kv) for kv in a.items()) 86 | 87 | def make_roles_objects(plan_roles): 88 | return map_dict(plan_roles, lambda k, v: (str(k), discord.Object(int(v)))) 89 | 90 | def str_values(d): 91 | return map_dict(d, lambda k, v: (k, str(v))) 92 | 93 | def update_rate_limited(user_id, rate_limit, rate_limit_table): 94 | now = time.time() 95 | time_gate = rate_limit_table.get(user_id, 0) 96 | if now > time_gate: 97 | rate_limit_table[user_id] = now + rate_limit 98 | return False 99 | return True 100 | 101 | def get_fanbox_pixiv_id(message): 102 | result = fanbox_id_prog.search(message) 103 | if result: 104 | return result.group(1) 105 | return None 106 | 107 | def setup_logging(log_file): 108 | logging.basicConfig( 109 | format='[%(asctime)s][%(levelname)s] %(message)s', 110 | level=logging.INFO, 111 | handlers=[ 112 | logging.FileHandler(log_file, encoding='utf-8'), 113 | logging.StreamHandler() 114 | ]) 115 | logging.getLogger('discord').setLevel(logging.WARNING) 116 | logging.getLogger('httpx').setLevel(logging.WARNING) 117 | 118 | def load_config(config_file): 119 | with open(config_file, 'r', encoding='utf-8') as f: 120 | config = obj(yaml.load(f, Loader=yaml.Loader)) 121 | config.admin_role_id = discord.Object(int(config.admin_role_id)) 122 | config.plan_roles = make_roles_objects(config.plan_roles) 123 | config.all_roles = list(config.plan_roles.values()) 124 | config.cleanup = obj(config.cleanup) 125 | config.auto_role_update = obj(config.auto_role_update) 126 | config.session_cookies = str_values(config.session_cookies) 127 | return config 128 | 129 | def parse_date(date_string): 130 | return datetime.datetime.fromisoformat(date_string) 131 | 132 | def days_in_month(date): 133 | return datetime.timedelta(days=calendar.monthrange(date.year, date.month)[1]) 134 | 135 | def compress_transactions(txns): 136 | new_txns = [] 137 | for _, group in itertools.groupby(txns, lambda x: x['targetMonth']): 138 | group = list(group) 139 | date = parse_date(group[0]['transactionDatetime']) 140 | new_txns.append({ 141 | 'fee': sum(map(lambda x: x['paidAmount'], group)), 142 | 'date': date, 143 | 'deltatime' : days_in_month(date), 144 | }) 145 | return new_txns 146 | 147 | def compute_last_subscription_range(txns): 148 | stop_date = datetime.datetime.min.replace(tzinfo=datetime.timezone.utc) 149 | txn_range = [] 150 | for txn in reversed(txns): 151 | date = txn['date'] 152 | if stop_date < date: 153 | txn_range.clear() 154 | stop_date = date + days_in_month(date) 155 | else: 156 | diff = abs(date - stop_date) 157 | stop_date = date + days_in_month(date) + diff 158 | txn_range.append(txn) 159 | return txn_range, stop_date 160 | 161 | # Alternate behavior for limiting transaction search scope to the current month or last month 162 | # if within the leeway period for the beginning of the month. 163 | def compute_limited_txn_range(txn_range, current_date, leeway_days): 164 | current_month_start = current_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0) 165 | leeway_date = current_month_start + datetime.timedelta(days=leeway_days) 166 | 167 | if current_date <= leeway_date: 168 | start_date = (current_month_start - datetime.timedelta(days=1)).replace(day=1) 169 | logging.debug(f'Checking transactions in last month or current month: {txn_range}') 170 | else: 171 | start_date = current_month_start 172 | logging.debug(f'Checking transactions only in current month: {txn_range}') 173 | return [txn for txn in txn_range if start_date <= txn['date'] <= current_date] 174 | 175 | def compute_plan_id(txns, plan_fee_lookup, current_date, leeway_days, limit_txn_range): 176 | # Ensure current_date is in UTC 177 | if current_date.tzinfo is None: 178 | current_date = current_date.replace(tzinfo=datetime.timezone.utc) 179 | txns = compress_transactions(txns) 180 | txn_range, stop_date = compute_last_subscription_range(txns) 181 | stop_date = stop_date + datetime.timedelta(days=abs(leeway_days)) 182 | 183 | if limit_txn_range: 184 | txn_range = compute_limited_txn_range(txn_range, current_date, leeway_days) 185 | if not txn_range: 186 | logging.debug('No valid transactions found.') 187 | return None 188 | elif stop_date < current_date or not txn_range: 189 | return None 190 | 191 | # When there is only one choice, skip most of the calculation. 192 | fee_types = {txn['fee'] for txn in txn_range} 193 | if len(fee_types) == 1: 194 | logging.debug(f'Single fee type found: {fee_types}') 195 | return plan_fee_lookup.get(fee_types.pop()) 196 | 197 | # When there are multiple choices, fill out the time table. 198 | days = [None] * abs((txn_range[0]['date'] - stop_date).days) 199 | 200 | start_date = txn_range[0]['date'] 201 | stop_idx = abs((start_date - current_date).days) 202 | 203 | for fee in sorted(plan_fee_lookup.keys(), reverse=True): 204 | for txn in txn_range: 205 | if fee == txn['fee']: 206 | day_idx = abs((start_date - txn['date']).days) 207 | for _ in range(txn['deltatime'].days): 208 | while days[day_idx] is not None: 209 | day_idx += 1 210 | days[day_idx] = fee 211 | 212 | # Remaining empty spaces will be caused by old plans that were never entered 213 | # into the plan fee lookup, usually because an old plan was removed. 214 | # Filling the empty spaces with the lowest plan will be the best effort resolution. 215 | days = days[max(stop_idx - 2, 0): min(stop_idx + 1, len(days) - 1)] 216 | min_fee = min(fee_types) 217 | days = [min_fee if day is None else day for day in days] 218 | 219 | logging.debug(f"Days array: {days}") 220 | return plan_fee_lookup.get(max(days)) 221 | 222 | def compute_highest_plan_id(txns, plan_fee_lookup): 223 | txns = compress_transactions(txns) 224 | if not txns: 225 | return None 226 | highest = max(txn['fee'] for txn in txns) 227 | # Best effort: Get the nearest plan in case there were plan value changes. 228 | return min(plan_fee_lookup.items(), key=lambda x: abs(highest - x[0]))[1] 229 | 230 | async def open_database(): 231 | db = await aiosqlite.connect(registry_db) 232 | await db.execute('create table if not exists user_data (pixiv_id integer not null primary key, data text)') 233 | await db.execute('create table if not exists member_pixiv (member_id integer not null primary key, pixiv_id integer)') 234 | await db.execute('create table if not exists plan_fee (fee numeric not null primary key, plan text)') 235 | return db 236 | 237 | async def reset_bindings_db(db): 238 | await db.execute('delete from member_pixiv') 239 | await db.execute('vacuum') 240 | await db.commit() 241 | 242 | async def get_user_data_db(db, pixiv_id): 243 | cursor = await db.execute('select data from user_data where pixiv_id = ?', (pixiv_id,)) 244 | user_data = await cursor.fetchone() 245 | if user_data is None: 246 | return None 247 | return json.loads(user_data[0]) 248 | 249 | async def update_user_data_db(db, pixiv_id, user_data): 250 | if user_data is None: 251 | return 252 | await db.execute('replace into user_data values(?, ?)', (pixiv_id, json.dumps(user_data))) 253 | await db.commit() 254 | 255 | async def get_member_pixiv_id_db(db, member_id): 256 | cursor = await db.execute('select pixiv_id from member_pixiv where member_id = ?', (member_id,)) 257 | result = await cursor.fetchone() 258 | if result is None: 259 | return None 260 | return result[0] 261 | 262 | async def update_member_pixiv_id_db(db, member_id, pixiv_id): 263 | await db.execute('replace into member_pixiv values(?, ?)', (member_id, pixiv_id)) 264 | await db.commit() 265 | 266 | async def get_members_by_pixiv_id_db(db, pixiv_id): 267 | cursor = await db.execute('select member_id from member_pixiv where pixiv_id = ?', (pixiv_id,)) 268 | result = await cursor.fetchall() 269 | return [r[0] for r in result] 270 | 271 | async def delete_member_db(db, member_id): 272 | await db.execute('delete from member_pixiv where member_id = ?', (member_id,)) 273 | await db.commit() 274 | 275 | async def get_plan_fees_db(db): 276 | cursor = await db.execute('select * from plan_fee') 277 | result = await cursor.fetchall() 278 | return {r[0]:r[1] for r in result} 279 | 280 | async def update_plan_fees_db(db, plan_fees): 281 | for k, v in plan_fees.items(): 282 | await db.execute('replace into plan_fee values(?, ?)', (k, v)) 283 | await db.commit() 284 | 285 | async def get_plan_fee_lookup(fanbox_client, db): 286 | cached_plans = await get_plan_fees_db(db) 287 | latest_plans = await fanbox_client.get_plans() 288 | latest_plans = {plan['fee']: plan['id'] for plan in latest_plans} 289 | latest_plans = cached_plans | latest_plans 290 | await update_plan_fees_db(db, latest_plans) 291 | return latest_plans 292 | 293 | def has_role(member, roles): 294 | if member is None: 295 | return False 296 | for role in roles: 297 | if member.get_role(role.id) is not None: 298 | return True 299 | return False 300 | 301 | async def main(): 302 | config = load_config(config_file) 303 | setup_logging(config.log_file) 304 | rate_limit_table = {} 305 | intents = discord.Intents.default() 306 | intents.members = True 307 | client = commands.Bot(command_prefix='!', intents=intents) 308 | fanbox_client = FanboxClient(config.session_cookies, config.session_headers) 309 | plan_fee_lookup = None 310 | db = None 311 | pending_exception = None 312 | 313 | async def stop_with_exception(ex): 314 | nonlocal pending_exception 315 | pending_exception = ex 316 | logging.exception(ex) 317 | for role in client.guilds[0].roles: 318 | if role.id == config.admin_role_id.id: 319 | for member in role.members: 320 | dm = await member.create_dm() 321 | await dm.send(f'{str(ex)} Unable to recover; Shutting down.') 322 | break 323 | await client.close() 324 | 325 | async def fetch_member(discord_id): 326 | try: 327 | return await client.guilds[0].fetch_member(discord_id) 328 | except: 329 | return None 330 | 331 | def role_from_supporting_plan(user_data): 332 | if user_data is None: 333 | return None 334 | plan = user_data['supportingPlan'] 335 | if plan is None: 336 | return None 337 | return config.plan_roles.get(plan['id']) 338 | 339 | def compute_role(user_data): 340 | if user_data is None: 341 | return None 342 | if config.only_check_highest_txn: 343 | plan_id = compute_highest_plan_id( 344 | user_data['supportTransactions'], 345 | plan_fee_lookup) 346 | else: 347 | plan_id = compute_plan_id( 348 | user_data['supportTransactions'], 349 | plan_fee_lookup, 350 | datetime.datetime.now(datetime.timezone.utc), 351 | config.auto_role_update.leeway_days, 352 | config.only_check_recent_txns) 353 | return config.plan_roles.get(plan_id) 354 | 355 | async def get_fanbox_user_data(pixiv_id, member=None, force_update=False): 356 | if pixiv_id is None: 357 | return None 358 | user_data = await get_user_data_db(db, pixiv_id) 359 | if not force_update: 360 | role = compute_role(user_data) 361 | # Checks to determine if cached used data should be updated from Fanbox. 362 | if force_update or role is None or not has_role(member, [role]): 363 | user_data = await fanbox_client.get_user(pixiv_id) 364 | await update_user_data_db(db, pixiv_id, user_data) 365 | return user_data 366 | 367 | async def get_all_fanbox_users(): 368 | all_users = await fanbox_client.get_all_users() 369 | return {int(user['user']['userId']): user['planId'] for user in all_users} 370 | 371 | async def set_member_role(member, role): 372 | if member is None: 373 | return False 374 | if role is None: 375 | if has_role(member, config.all_roles): 376 | await member.remove_roles(*config.all_roles) 377 | return True 378 | return False 379 | elif not has_role(member, [role]): 380 | await member.remove_roles(*config.all_roles) 381 | await member.add_roles(role) 382 | return True 383 | return False 384 | 385 | async def update_role_check_by_txn(member:discord.Member): 386 | if not has_role(member, config.all_roles): 387 | return 388 | pixiv_id = await get_member_pixiv_id_db(db, member.id) 389 | user_data = await get_fanbox_user_data(pixiv_id, member=member) 390 | role = compute_role(user_data) 391 | if role is None: 392 | role = role_from_supporting_plan(user_data) 393 | if await set_member_role(member, role): 394 | logging.info(f'Set role: member: {member} pixiv_id: {pixiv_id} role: {role}') 395 | 396 | async def update_role_check_all_members_by_txn(): 397 | guild = client.guilds[0] 398 | logging.info(f'Begin update role check: {guild.member_count} members') 399 | count = 0 400 | async for member in guild.fetch_members(limit=None): 401 | try: 402 | await update_role_check_by_txn(member) 403 | count += 1 404 | except AuthException as ex: 405 | raise ex 406 | except Exception as ex: 407 | logging.exception(ex) 408 | logging.info(f'End update role check: {count} checked') 409 | 410 | async def update_role_check_by_list(member:discord.Member, supporters): 411 | pixiv_id = await get_member_pixiv_id_db(db, member.id) 412 | if pixiv_id is None: 413 | return 414 | plan_id = supporters.get(pixiv_id) 415 | role = config.plan_roles.get(plan_id) 416 | if await set_member_role(member, role): 417 | logging.info(f'Set role: member: {member} pixiv_id: {pixiv_id} role: {role}') 418 | 419 | async def update_role_check_all_members_by_list(): 420 | guild = client.guilds[0] 421 | logging.info(f'Begin update role check: {guild.member_count} members') 422 | count = 0 423 | all_fanbox_users = await get_all_fanbox_users() 424 | async for member in guild.fetch_members(limit=None): 425 | try: 426 | await update_role_check_by_list(member, all_fanbox_users) 427 | count += 1 428 | except AuthException as ex: 429 | raise ex 430 | except Exception as ex: 431 | logging.exception(ex) 432 | logging.info(f'End update role check: {count} checked') 433 | 434 | async def update_role_check_all_members(): 435 | if config.only_check_current_sub: 436 | await update_role_check_all_members_by_list() 437 | else: 438 | await update_role_check_all_members_by_txn() 439 | 440 | async def get_fanbox_role_with_pixiv_id(pixiv_id): 441 | user_data = await get_fanbox_user_data(pixiv_id, force_update=True) 442 | if config.only_check_current_sub: 443 | return role_from_supporting_plan(user_data) 444 | else: 445 | role = compute_role(user_data) 446 | if role is None: 447 | role = role_from_supporting_plan(user_data) 448 | return role 449 | 450 | async def reset(): 451 | guild = client.guilds[0] 452 | count = 0 453 | async for member in guild.fetch_members(limit=None): 454 | try: 455 | await member.remove_roles(*config.all_roles) 456 | except: 457 | pass 458 | count += 1 459 | await reset_bindings_db(db) 460 | return count 461 | 462 | def is_old_member(joined_at): 463 | return joined_at + datetime.timedelta(hours=config.cleanup.member_age_hours) <= datetime.datetime.now(joined_at.tzinfo) 464 | 465 | async def purge(): 466 | guild = client.guilds[0] 467 | names = [] 468 | async for member in guild.fetch_members(limit=None): 469 | if len(member.roles) == 1 and is_old_member(member.joined_at): 470 | try: 471 | await member.kick(reason="Purge: No role assigned") 472 | names.append(member.name) 473 | except: 474 | pass 475 | if len(names) > 0: 476 | logging.info(f'purged {len(names)} users without roles: {names}') 477 | return names 478 | 479 | async def cleanup(): 480 | try: 481 | await purge() 482 | except Exception as ex: 483 | logging.exception(ex) 484 | 485 | async def respond(message, condition, **kwargs): 486 | logging.info(f'User: {message.author}; Message: "{message.content}"; Response: {condition}') 487 | await message.channel.send(config.system_messages[condition].format(**kwargs)) 488 | 489 | async def handle_access(message): 490 | member = await fetch_member(message.author.id) 491 | 492 | if not member: 493 | logging.info(f'User: {message.author}; Message: "{message.content}"; Not a member, ignored') 494 | return 495 | 496 | if update_rate_limited(message.author.id, config.rate_limit, rate_limit_table): 497 | await respond(message, 'rate_limited', rate_limit=config.rate_limit) 498 | return 499 | 500 | pixiv_id = get_fanbox_pixiv_id(message.content) 501 | 502 | if pixiv_id is None: 503 | await respond(message, 'no_id_found') 504 | return 505 | 506 | if config.strict_access: 507 | members = await get_members_by_pixiv_id_db(db, pixiv_id) 508 | if members and member.id not in members: 509 | await respond(message, 'id_bound', id=pixiv_id) 510 | return 511 | 512 | role = await get_fanbox_role_with_pixiv_id(pixiv_id) 513 | 514 | if not role: 515 | await respond(message, 'access_denied', id=pixiv_id) 516 | return 517 | 518 | await update_member_pixiv_id_db(db, member.id, pixiv_id) 519 | 520 | await set_member_role(member, role) 521 | 522 | await respond(message, 'access_granted') 523 | 524 | @client.command(name='add-user') 525 | async def add_user(ctx, pixiv_id, discord_id): 526 | member = await fetch_member(discord_id) 527 | 528 | if not member: 529 | await ctx.send(f'{discord_id} is not in the server.') 530 | return 531 | 532 | role = await get_fanbox_role_with_pixiv_id(pixiv_id) 533 | 534 | if not role: 535 | await ctx.send(f'{member} access denied.') 536 | return 537 | 538 | await update_member_pixiv_id_db(db, member.id, pixiv_id) 539 | 540 | await set_member_role(member, role) 541 | 542 | await ctx.send(f'{member} access granted.') 543 | 544 | @client.command(name='unbind-user-by-discord-id') 545 | async def unbind_user_by_discord_id(ctx, discord_id): 546 | pixiv_id = await get_member_pixiv_id_db(db, discord_id) 547 | await delete_member_db(db, discord_id) 548 | member = await fetch_member(discord_id) 549 | await set_member_role(member, None) 550 | if member is not None: 551 | member = member.name 552 | await ctx.send(f'unbound user {(discord_id, member)} with pixiv_id {pixiv_id}') 553 | 554 | @client.command(name='unbind-user-by-pixiv-id') 555 | async def unbind_user_by_pixiv_id(ctx, pixiv_id): 556 | member_ids = await get_members_by_pixiv_id_db(db, pixiv_id) 557 | for member_id in member_ids: 558 | await unbind_user_by_discord_id(ctx, member_id) 559 | 560 | @client.command(name='get-by-discord-id') 561 | async def get_by_discord_id(ctx, discord_id): 562 | member = await fetch_member(discord_id) 563 | if member is not None: 564 | member = member.name 565 | pixiv_id = await get_member_pixiv_id_db(db, discord_id) 566 | await ctx.send(f'member {(discord_id, member)} pixiv_id {pixiv_id}') 567 | 568 | @client.command(name='get-by-pixiv-id') 569 | async def get_by_pixiv_id(ctx, pixiv_id): 570 | member_ids = await get_members_by_pixiv_id_db(db, pixiv_id) 571 | for member_id in member_ids: 572 | await get_by_discord_id(ctx, member_id) 573 | 574 | @client.command(name='reset') 575 | async def _reset(ctx): 576 | count = await reset() 577 | await ctx.send(f'removed roles from {count} users') 578 | 579 | @client.command(name='purge') 580 | async def _purge(ctx): 581 | names = await purge() 582 | await ctx.send(f'purged {len(names)} users without roles: {names}') 583 | 584 | @client.command(name='test-id') 585 | async def test_id(ctx, id): 586 | role = await get_fanbox_role_with_pixiv_id(id) 587 | await ctx.send(f'Role: {role}') 588 | 589 | @client.command(name='export-csv') 590 | async def export_csv(ctx): 591 | try: 592 | guild = client.guilds[0] 593 | fileobj = io.StringIO() 594 | writer = csv.writer(fileobj) 595 | writer.writerow(['Discord User', 'Discord ID', 'Pixiv User', 'Pixiv ID', 'Discord Join Date', 'Fanbox Join Date']) 596 | async for member in guild.fetch_members(limit=None): 597 | pixiv_id = await get_member_pixiv_id_db(db, member.id) 598 | if pixiv_id is None: 599 | continue 600 | user_data = await get_user_data_db(db, pixiv_id) 601 | oldest_txn = None 602 | if user_data['supportTransactions']: 603 | oldest_txn = user_data['supportTransactions'][-1]['transactionDatetime'] 604 | writer.writerow([member.name, member.id, user_data['user']['name'], pixiv_id, member.joined_at, oldest_txn]) 605 | fileobj.seek(0) 606 | await ctx.send(file=discord.File(fileobj, filename='export.csv')) 607 | except Exception as ex: 608 | logging.exception(ex) 609 | await ctx.send(f'Exception: {ex}') 610 | 611 | @client.event 612 | async def on_ready(): 613 | if len(client.guilds) > 1: 614 | logging.warning('This bot has been invited to more than 1 server. The bot may not work correctly.') 615 | logging.info(f'{client.user} has connected to Discord!') 616 | 617 | try: 618 | nonlocal plan_fee_lookup 619 | plan_fee_lookup = await get_plan_fee_lookup(fanbox_client, db) 620 | check_plans() 621 | 622 | async with asyncio.TaskGroup() as tg: 623 | if config.cleanup.run: 624 | tg.create_task(periodic(cleanup, config.cleanup.period_hours * 60 * 60)) 625 | 626 | if config.auto_role_update.run: 627 | tg.create_task(periodic(update_role_check_all_members, config.auto_role_update.period_hours * 60 * 60)) 628 | except* AuthException as ex: 629 | await stop_with_exception(ex) 630 | 631 | @client.event 632 | async def on_message(message): 633 | if (message.author == client.user 634 | or message.channel.type != discord.ChannelType.private 635 | or message.content == ''): 636 | return 637 | 638 | try: 639 | is_admin = has_role(await fetch_member(message.author.id), [config.admin_role_id]) 640 | if config.operator_mode and not is_admin: 641 | return 642 | if is_admin and message.content.startswith('!'): 643 | await client.process_commands(message) 644 | else: 645 | await handle_access(message) 646 | 647 | except AuthException as ex: 648 | await respond(message, 'system_error') 649 | await stop_with_exception(ex) 650 | except Exception as ex: 651 | logging.exception(ex) 652 | await respond(message, 'system_error') 653 | 654 | def check_plans(): 655 | configured_plans = set(config.plan_roles.keys()) 656 | fanbox_plans = set(plan_fee_lookup.values()) 657 | config_missing = configured_plans - fanbox_plans 658 | fanbox_missing = fanbox_plans - configured_plans 659 | if config_missing: 660 | logging.warning(f'The config file contains plans that were not found on Fanbox (including deleted plans): {config_missing}') 661 | if fanbox_missing: 662 | logging.warning(f'Fanbox may contain plans (including deleted plans) that were not found in the config file: {fanbox_missing}') 663 | 664 | try: 665 | db = await open_database() 666 | token = config.operator_token if config.operator_mode else config.discord_token 667 | await client.start(token, reconnect=False) 668 | except Exception as ex: 669 | logging.exception(ex) 670 | finally: 671 | if not client.is_closed(): 672 | await client.close() 673 | await db.close() 674 | 675 | if pending_exception: 676 | raise pending_exception 677 | 678 | delay = 10 679 | logging.warning(f'Disconnected: reconnecting in {delay}s') 680 | await asyncio.sleep(delay) 681 | 682 | def run_main(): 683 | asyncio.run(main()) 684 | 685 | async def db_migration(): 686 | import pickle 687 | import os 688 | if not os.path.exists('registry.dat'): 689 | return 690 | print('Found registry.dat: Starting DB migration') 691 | with open('registry.dat', 'rb') as f: 692 | reg = pickle.load(f) 693 | config = load_config(config_file) 694 | client = FanboxClient(config.session_cookies, config.session_headers) 695 | db = await open_database() 696 | for discord_id, pixiv_ids in reg['discord_ids'].items(): 697 | for pixiv_id in pixiv_ids: 698 | try: 699 | user_data = await client.get_user(pixiv_id) 700 | except: 701 | continue 702 | if user_data is None: 703 | continue 704 | print(f'user {discord_id} {pixiv_id}') 705 | await update_member_pixiv_id_db(db, discord_id, pixiv_id) 706 | await update_user_data_db(db, pixiv_id, user_data) 707 | break 708 | await db.close() 709 | os.rename('registry.dat', 'registry.dat.backup') 710 | print('Moved registry.dat to registry.dat.backup') 711 | print('DB migration finished') 712 | 713 | if __name__ == '__main__': 714 | asyncio.run(db_migration()) 715 | 716 | with concurrent.futures.ProcessPoolExecutor(max_workers=1) as pool: 717 | while True: 718 | # Because discord.py is not closing aiohttp clients correctly, 719 | # the process has to be completely restarted to get into a good state. 720 | # If disconnects are frequent, the periodic cleanup function may never run. 721 | # A new discord client could be created, but then aiohttp sockets may leak, 722 | # and eventually resources would be exhausted. 723 | try: 724 | future = pool.submit(run_main) 725 | future.result() 726 | except* AuthException as ex: 727 | logging.critical("An unrecoverable exception occurred, waiting forever...") 728 | while True: 729 | time.sleep(1000) 730 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.9.0b0 2 | aiosqlite==0.19.0 3 | discord.py==2.3.2 4 | httpx==0.23.3 5 | httpx_caching==0.1a3 6 | PyYAML==6.0.1 7 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import { } }: 2 | pkgs.mkShell { 3 | nativeBuildInputs = with pkgs.buildPackages; [ 4 | python312 5 | ]; 6 | shellHook = '' 7 | python -m venv venv 8 | source venv/bin/activate 9 | ''; 10 | } -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import main 2 | import logging 3 | 4 | logging.basicConfig( 5 | format='[%(asctime)s][%(levelname)s] %(message)s', 6 | level=logging.DEBUG, 7 | ) 8 | 9 | def filter_future_dates(txns, current_date): 10 | return [txn for txn in txns if main.parse_date(txn['transactionDatetime']) <= current_date] 11 | 12 | test_plan_fee_lookup = { 13 | 500: '1', 14 | 1000: '2', 15 | 1500: '3', 16 | } 17 | 18 | test_txns = [ 19 | { 20 | 'paidAmount': 500, 21 | 'transactionDatetime': '2024-05-14T00:00:00+09:00', 22 | 'targetMonth': '2024-05', 23 | }, 24 | { 25 | 'paidAmount': 500, 26 | 'transactionDatetime': '2024-04-01T00:00:00+09:00', 27 | 'targetMonth': '2024-04', 28 | }, 29 | { 30 | 'paidAmount': 500, 31 | 'transactionDatetime': '2024-03-15T00:00:00+09:00', 32 | 'targetMonth': '2024-03', 33 | }, 34 | { 35 | 'paidAmount': 400, 36 | 'transactionDatetime': '2024-03-16T00:00:00+09:00', 37 | 'targetMonth': '2024-03', 38 | }, 39 | ] 40 | 41 | current_date = main.parse_date('2024-06-01T00:00:01+09:00') 42 | 43 | test_txns = filter_future_dates(test_txns, current_date) 44 | 45 | print(main.compute_plan_id(test_txns, test_plan_fee_lookup, current_date, 5, True)) 46 | print(main.compute_highest_plan_id(test_txns, test_plan_fee_lookup)) --------------------------------------------------------------------------------