diff --git a/.env b/.env new file mode 100644 index 0000000..98bfe97 --- /dev/null +++ b/.env @@ -0,0 +1,14 @@ +DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} +GUILD_ID=XXXXXXXXXXXXXXXXXX +ADMIN_ID_LIST=XXXXXXXXXXXXXXXXXX +REPORT_CHANNEL_ID=XXXXXXXXXXXXXXXXXX +VERIFY_CHANNEL_ID=XXXXXXXXXXXXXXXXXX +ADMIN_ROLE_ID=XXXXXXXXXXXXXXXXXX + +PAYPAL_CLIENT_ID=${{ secrets.PAYPAL_CLIENT_ID }} +PAYPAL_CLIENT_SECRET=${{ secrets.PAYPAL_CLIENT_SECRET }} + +RESOURCE_LIST="plugin_name0:role_id0,role_id1;plugin_name1:role_id0,role_id2" + +APPEAR_OFFLINE=True +CHECK_PREVIOUSLY_VERIFIED=True \ No newline at end of file diff --git a/.env example b/.env example deleted file mode 100644 index 563a164..0000000 --- a/.env example +++ /dev/null @@ -1,9 +0,0 @@ -DISCORD_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -GUILD_LIST="xxxxxxxxxxxxxxxxxx" -ADMIN_ID_LIST="xxxxxxxxxxxxxxxxxx" - -PAYPAL_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -PAYPAL_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx - -RESOURCE_LIST="0000:verified-plugin0 1111:verified-plugin1" - diff --git a/.gitignore b/.gitignore index db954ed..16752c9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ verified_emails *.log .DS_Store +.idea +venv +*.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4c27c68 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3 + +WORKDIR /usr/src/app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD [ "python", "./main.py" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index dc0c04f..d043475 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,80 @@ # VerifyPurchaseDiscordBot -Discord bot that searches your PayPal transactions (by user entered email) and assigns a role if the purchase was verified. +Discord bot that searches your PayPal transactions (via user email) and assigns a role if the purchase has been verified. -![](https://i.imgur.com/qE83p4R.png) +This bot supports [SpigotMC](https://www.spigotmc.org/) and [MCMarket](https://www.mc-market.org/) -![](https://i.imgur.com/BCNGeJW.png) +**ScreenShot** -![](https://i.imgur.com/IC70YYD.png) +Some pics of the Discord bot -There is also an option to check for previously verified emails! +![](https://i.imgur.com/yoDuzS7.png) + +![](https://imgur.com/J0wHgl1.png) + +![](https://imgur.com/fj58XJO.png) +![](https://imgur.com/Uv0UhVm.png) +![](https://imgur.com/DFJPUfC.png) +![](https://imgur.com/ihRk6tE.png) -![](https://i.imgur.com/6Y6iGJO.png) **Steps for getting started:** - install the libraries in requirements.txt - ```python -m pip install -r requirements.txt``` -- rename ".env example" to ".env" **Guide to filling out the .env file:** --- ``` -DISCORD_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +DISCORD_TOKEN=Ojzk1MTM2Mc0NjYTATQy2Mkz.gfqYfq99A.JScoVbGD1Lo0HDbonDuvYjJPtPy ``` - - Create a new discord application and put the token value here. (https://discord.com/developers/applications) + - Create a new discord application and put the token value here, [click here](https://discord.com/developers/applications) - Make sure to set the Oauth2 scope to: [bot, applications.commands] ``` -GUILD_LIST="xxxxxxxxxxxxxxxxxx" +GUILD_ID="897460772427382670" ``` -- Put any guild ids (discord server ids) in here you want to use the bot on (seperated by spaces within the string) -- If you don't know your discord server id, find it like this: https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID- +- Put here the guild id (discord server id) you want to use the bot on +- If you don't know your discord server id, [click here](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-) ``` -ADMIN_ID_LIST="xxxxxxxxxxxxxxxxxx" +ADMIN_ID_LIST="143651103467110401 290472907317051392" +``` +- Put here any admin role ids (discord role ids) that you want the bot to private message (seperated by spaces within the string) +- If you don't know a discord user id, [click here](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-) +``` +ADMIN_ROLE_ID="945380919903150090" +``` +- Put here the admin role id (discord role id) that you want to allow to write in the verification channel, otherwise the bot will delete the messages +- If you don't know the discord role id [click here](https://ozonprice.com/blog/discord-get-role-id/) +``` +REPORT_CHANNEL_ID="945380919903150090" ``` -- Put any admin ids (discord user ids) in here you that you want the bot to private message (seperated by spaces within the string) -- ![](https://i.imgur.com/X61GB6a.png) -- If you don't know a discord user id, find it like this: https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID- +- Put here the channel id (discord channel id) where the bot will send a message with the success of the verification every time someone will use the verification command +- If you don't know the discord channel id [click here](https://turbofuture.com/internet/Discord-Channel-ID) +``` +VERIFY_CHANNEL_ID="945380919903150090" +``` +- Put here the channel id (discord channel id) where users can only use the verification command +- Make sure that users can write within that channel +- If you don't know the discord channel id [click here](https://turbofuture.com/internet/Discord-Channel-ID) ``` PAYPAL_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx PAYPAL_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` -- Create a PayPal API application in a 'Live' environment (https://developer.paypal.com/docs/api-basics/manage-apps/) +- Create a PayPal API application in a 'Live' environment [click here](https://developer.paypal.com/docs/api-basics/manage-apps/) - Make sure to grant the application access to 'Transaction Search' ``` -RESOURCE_LIST="0000:verified-plugin0 1111:verified-plugin1" +RESOURCE_LIST="PluginName1:846789230670774275,846789230670774276;PluginName2:RoleId1,RoleId2" ``` -- Put your Spigot resource id here (found in your Spigot resource URL) followed by the Discord role you want to assign to a user once they have verified a purchase for that id. (This role must already exist on your server) -- Put as many of these as you have separated by spaces within the string like in the example above +- Put your Spigot or McMarket resource name here followed by the Discord roles (comma-separated) you want to assign to a user once they have verified a purchase for that plugin name. (These roles must already exist on your server) +- Put as many of these as you have separated by semicolon within the string like in the example above + --- -\ Now just run the bot wherever you are going to host it (and make sure it has a sufficient role to assign roles to users): ``` -py -3 verifybot.py +py -3 main.py ``` - -Enjoy! - -[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=WYC2DQJMWUX6J) \ +[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/donate/?hosted_button_id=ZETC226F4FWB6) \ **If this bot is helpful to you, please consider donating.** diff --git a/main.py b/main.py new file mode 100644 index 0000000..3e2ffa4 --- /dev/null +++ b/main.py @@ -0,0 +1,146 @@ +import asyncio +import logging +import discord +from discord.ext import commands +from discord.utils import get +from discord_slash import SlashCommand +from discord_slash.utils.manage_commands import create_option +import os +from dotenv import load_dotenv +from paypal_api import PayPalApi +from verify_bot import VerifyBot, AlreadyVerifiedPurchases, AlreadyVerifiedEmail, VerificationFailed + +load_dotenv() +DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") +GUILD_ID = [int(os.environ.get("GUILD_ID"))] +REPORT_CHANNEL_ID = int(os.environ.get("REPORT_CHANNEL_ID")) +VERIFY_CHANNEL_ID = int(os.environ.get("VERIFY_CHANNEL_ID")) +ADMIN_ROLE_ID = int(os.environ.get("ADMIN_ROLE_ID")) +ADMIN_ID_LIST = [] +if bool(os.environ.get("ADMIN_ID_LIST") and os.environ.get("ADMIN_ID_LIST").strip()): + ADMIN_ID_LIST = [int(i) for i in os.environ.get("ADMIN_ID_LIST").split(" ")] +APPEAR_OFFLINE = os.getenv("APPEAR_OFFLINE").lower() == "true" +client = discord.Client(intents=discord.Intents.all()) +bot = commands.Bot(command_prefix='!') +slash = SlashCommand(client, sync_commands=True) +paypal_api = PayPalApi() +verify_bot = VerifyBot(paypal_api) + + +@client.event +async def on_message(message): + if message.author.id == client.user.id: + return + role = discord.utils.get(message.guild.roles, id=ADMIN_ROLE_ID) + if role not in message.author.roles and message.channel.id == VERIFY_CHANNEL_ID: + await message.delete() + + +# discord bot command to add a role to a user +@bot.command(pass_context=True) +async def add_role(ctx, role_id): + member = ctx.author + role = get(member.guild.roles, id=int(role_id)) + await member.add_roles(role) + + +# send a direct message to a list of admins +@bot.command(pass_context=True) +async def dm_admins(ctx, email, username, roles_given, verified): + if verified: + message = "{} successfully verified a purchase with email: ".format( + ctx.author.mention) + f"{email} and username: {username}. Given roles: " + roles = [(get(ctx.guild.roles, id=int(role_id))).name for role_id in roles_given] + message = message + str(roles) + else: + message = "{} failed to verify a purchase with email: ".format( + ctx.author.mention) + f"{email} and username: {username}" + for user_id in ADMIN_ID_LIST: + user = ctx.author.guild.get_member(user_id) + await user.send(message) + + +# send a report message into a channel +@bot.command(pass_context=True) +async def channel_message(author, email, username, roles, verified): + channel = client.get_channel(REPORT_CHANNEL_ID) + roles_message = "" + if verified: + embed = discord.Embed(title="Purchase verify of premium plugins", + description="Purchase verification completed for {}!".format(author.name), + color=0x2ecc71) + for role_id in roles: + roles_message = roles_message + f"<@&{role_id}> " + else: + embed = discord.Embed(title="Purchase verify of premium plugins", + description="Purchase verification failed for {}!".format(author.name), + color=0xe74c3c) + embed.add_field(name="Email", value=email, inline=True) + embed.add_field(name="Username", value=username, inline=True) + if verified: + embed.add_field(name="Roles", value=roles_message, inline=False) + await channel.send(embed=embed) + + +# discord event that fires when the bot is ready and listening +@client.event +async def on_ready(): + logging.basicConfig(handlers=[logging.FileHandler('data/verifybot.log', 'a+', 'utf-8')], level=logging.INFO, + format='%(asctime)s: %(message)s') + + if APPEAR_OFFLINE: + await client.change_presence(status=discord.Status.offline) + + print("The bot is ready!") + + +# defines a new 'slash command' in discord and what options to show to user for params +@slash.slash(name="verify", + description="Verify your plugins purchase.", + options=[ + create_option( + name="email", + description="Your paypal email.", + option_type=3, + required=True + ), + create_option( + name="username", + description="Your SpigotMc or McMarket username.", + option_type=3, + required=True + ) + ], + guild_ids=GUILD_ID) +async def _verifypurchase(ctx, email: str, username: str): + if not (ctx.channel.id == VERIFY_CHANNEL_ID): + return + + try: + roles_to_give = await verify_bot.verify(ctx, email, username) + if roles_to_give: + for role in roles_to_give: + await add_role(ctx, role) + logging.info(f"{ctx.author.name} given role: " + role) + + await ctx.send(f"Successfully verified plugin purchase!", hidden=True) + await channel_message(ctx.author, email, username, roles_to_give, True) + await dm_admins(ctx, email, username, roles_to_give, True) + logging.info(f"{ctx.author.name} successfully verified their purchase") + asyncio.create_task(verify_bot.write_out_emails()) + except AlreadyVerifiedPurchases: + await ctx.send(f"You have already verified your purchase(s)!", hidden=True) + logging.info(f"{ctx.author.name} already had all verified roles.") + return + except AlreadyVerifiedEmail: + await ctx.send(f"Purchase already verified with this email!", hidden=True) + logging.info(f"{ctx.author.name} already verified email.") + except VerificationFailed: + await ctx.send("Failed to verify plugin purchase, open a ticket.", hidden=True) + await channel_message(ctx.author, email, username, [], False) + await dm_admins(ctx, email, username, [], False) + logging.info(f"{ctx.author.name} failed to verify their purchase") + + +# run the discord client with the discord token +client.run(DISCORD_TOKEN) diff --git a/paypal_api.py b/paypal_api.py new file mode 100644 index 0000000..f09fe25 --- /dev/null +++ b/paypal_api.py @@ -0,0 +1,59 @@ +import json +import logging +import os +from threading import Timer +import requests +from dotenv import load_dotenv + + +def format_date(date): + return date.strftime('%Y-%m-%dT%H:%M:%SZ') + + +class PayPalApi: + + def __init__(self): + load_dotenv() + self.PAYPAL_CLIENT_ID = os.getenv("PAYPAL_CLIENT_ID") + self.PAYPAL_CLIENT_SECRET = os.getenv("PAYPAL_CLIENT_SECRET") + self.PAYPAL_ENDPOINT = "https://api-m.paypal.com" + self.PAYPAL_TOKEN = 0 + self.update_token() + + def get_transactions(self, start_date, end_date): + url = self.PAYPAL_ENDPOINT + "/v1/reporting/transactions" + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.PAYPAL_TOKEN}" + } + + payload = { + 'start_date': f'{format_date(start_date)}', + 'end_date': f'{format_date(end_date)}', + 'transaction_status': 'S', + 'fields': 'cart_info, payer_info' + } + + response = requests.get(url, headers=headers, params=payload) + + return json.loads(response.text) + + def update_token(self): + url = self.PAYPAL_ENDPOINT + '/v1/oauth2/token' + + payload = { + "grant_type": "client_credentials" + } + + response = requests.post(url, auth=(self.PAYPAL_CLIENT_ID, self.PAYPAL_CLIENT_SECRET), data=payload) + data = response.json() + + # keep the token alive + token_expire = int(data['expires_in']) - 60 + t = Timer(token_expire, self.update_token) + t.daemon = True + t.start() + + logging.info(f"Got new access token.") + self.PAYPAL_TOKEN = data['access_token'] diff --git a/requirements.txt b/requirements.txt index 22d1009..afa3dde 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # install these using 'python -m pip install -r requirements.txt' -discord.py +discord.py==1.7.3 python-dotenv -discord-py-slash-command -requests \ No newline at end of file +discord-py-slash-command==3.0.3 +requests +python-dateutil \ No newline at end of file diff --git a/verify_bot.py b/verify_bot.py new file mode 100644 index 0000000..e73c202 --- /dev/null +++ b/verify_bot.py @@ -0,0 +1,216 @@ +import json +import logging +import os +import re +from datetime import datetime, timedelta +from threading import Timer + +import discord +from dotenv import load_dotenv +from dateutil import parser +import paypal_api +from pathlib import Path + +regex = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b' + + +def isValid(email): + return re.fullmatch(regex, email) + + +class AlreadyVerifiedPurchases(Exception): + pass + + +class AlreadyVerifiedEmail(Exception): + pass + + +class VerificationFailed(Exception): + pass + + +class VerifyBot: + def __init__(self, paypal_api: paypal_api.PayPalApi): + self.database = {} + self.RESOURCES = {} + self.verified_emails = {} + load_dotenv() + for resource in os.environ.get("RESOURCE_LIST").split(";"): + resource_name = resource.split(":")[0].lower() + resource_roles = (resource.split(":")[1]).split(",") + self.RESOURCES[resource_name] = resource_roles + self.CHECK_PREVIOUSLY_VERIFIED = os.getenv("CHECK_PREVIOUSLY_VERIFIED").lower() == "true" + self.paypal_api = paypal_api + self.read_in_emails() + print("Loading purchases...") + self.update_purchases_task() + + def update_purchases_task(self): + self.update_purchases() + t = Timer(3600, self.update_purchases_task) + t.daemon = True + t.start() + + def read_in_emails(self): + if self.CHECK_PREVIOUSLY_VERIFIED: + try: + with open('data/verified_emails.json') as file: + self.verified_emails = json.load(file) + except FileNotFoundError: + pass + + async def write_out_emails(self): + if self.CHECK_PREVIOUSLY_VERIFIED: + with open('data/verified_emails.json', 'w') as outfile: + json.dump(self.verified_emails, outfile, indent=2) + + def has_previously_verified(self, email: str, resource_name: str): + return self.CHECK_PREVIOUSLY_VERIFIED and email in self.verified_emails and resource_name.lower() in \ + self.verified_emails[email]["purchases"] + + def get_previously_verified_purchases(self, email: str): + resources = [] + if self.CHECK_PREVIOUSLY_VERIFIED: + if email in self.verified_emails: + for name in self.verified_emails[email]["purchases"]: + if self.has_previously_verified(email, name): + resources.append(name) + + if resources: + resources.sort() + return self.verified_emails[email]["discord_id"], resources + + return None, resources + + def add_previously_verified(self, email: str, discord_id: int, resource_name: str): + if self.CHECK_PREVIOUSLY_VERIFIED: + if email in self.verified_emails and resource_name not in self.verified_emails[email]["purchases"]: + self.verified_emails[email]["purchases"].append(resource_name.lower()) + else: + self.verified_emails[email] = { + 'discord_id': discord_id, + 'purchases': [resource_name.lower()] + } + + def find_plugin_name(self, text: str): + for resource_name in self.RESOURCES.keys(): + if resource_name.lower() in text.lower(): + return resource_name.lower() + return + + def find_purchases_by_email(self, email: str): + purchases = self.database.get("customers").get(email) + if purchases: + purchases.sort() + return purchases + + def find_purchases(self, transactions): + try: + for transaction in transactions["transaction_details"]: + try: + purchase_item_name = transaction['cart_info']['item_details'][0]['item_name'] + purchase_email = transaction['payer_info']['email_address'].lower() + plugin_name = self.find_plugin_name(purchase_item_name) + if plugin_name: + user_purchases = [] + if self.database.get("customers").get(purchase_email): + user_purchases = self.database["customers"].get(purchase_email) + if plugin_name not in user_purchases: + user_purchases.append(plugin_name) + self.database["customers"][purchase_email] = user_purchases + else: + self.database["customers"][purchase_email] = [plugin_name] + except KeyError: + pass + except IndexError: + pass + except KeyError: + pass + + def update_purchases(self): + if not self.database: + try: + with open('data/database.json') as file: + self.database = json.load(file) + except FileNotFoundError: + pass + + if not self.database.get("last_update") or not all( + x == y for x, y in zip(self.database["saved_plugins"], self.RESOURCES.keys())): + + end_date = datetime.utcnow() + self.database["last_update"] = end_date + self.database["customers"] = {} + count = 0 + while count < 36: + start_date = end_date - timedelta(days=31) + transactions = self.paypal_api.get_transactions(start_date, end_date) + self.find_purchases(transactions) + end_date = start_date + count = count + 1 + else: + end_date = datetime.utcnow() + last_update = parser.parse(self.database["last_update"]) + self.database["last_update"] = end_date + count = 0 + while count < 36 and end_date >= last_update: + start_date = end_date - timedelta(days=31) + transactions = self.paypal_api.get_transactions(start_date, end_date) + self.find_purchases(transactions) + end_date = start_date + count = count + 1 + + Path("data").mkdir(parents=True, exist_ok=True) + with open('data/database.json', 'w') as outfile: + self.database["last_update"] = self.database["last_update"].isoformat() + self.database["saved_plugins"] = list(self.RESOURCES.keys()) + json.dump(self.database, outfile, indent=2) + + async def verify(self, ctx, email: str, username: str): + + if not isValid(email): + await ctx.send(f"You must provide a valid email!", hidden=True) + return + + email = email.lower() + + logging.info(f"{ctx.author.name} ran command '/verify {email} {username}'") + available_roles = [] + + for roles in self.RESOURCES.values(): + for role_id in roles: + role = discord.utils.find(lambda r: r.id == int(role_id), ctx.author.guild.roles) + if role not in ctx.author.roles: + available_roles.append(role_id) + + if len(available_roles) == 0: + raise AlreadyVerifiedPurchases() + + await ctx.defer(hidden=True) + self.update_purchases() + user_id, verified_purchases = self.get_previously_verified_purchases(email) + purchases = self.find_purchases_by_email(email) + + if not purchases: + raise VerificationFailed() + + if user_id and user_id != ctx.author.id: + purchases = [item for item in purchases if item not in verified_purchases] + + if not purchases: + raise AlreadyVerifiedEmail() + + roles_to_give = [] + for purchase in purchases: + roles = self.RESOURCES.get(purchase) + if roles: + roles_to_give = roles_to_give + [value for value in roles if value in available_roles] + if roles_to_give: + self.add_previously_verified(email, ctx.author.id, purchase) + + if (self.CHECK_PREVIOUSLY_VERIFIED and verified_purchases == purchases and len(roles_to_give) == 0) or ( + not self.CHECK_PREVIOUSLY_VERIFIED and len(roles_to_give) == 0): + raise AlreadyVerifiedPurchases() + + return roles_to_give diff --git a/verifybot.py b/verifybot.py deleted file mode 100644 index e7524ac..0000000 --- a/verifybot.py +++ /dev/null @@ -1,310 +0,0 @@ -import requests -import json -import pickle -import logging -from threading import Timer -import discord -from discord_slash import SlashCommand -from discord_slash.utils.manage_commands import create_option -from discord.utils import get -from discord.ext import commands -from discord.ext.commands import Bot - -import os -from dotenv import load_dotenv - -from datetime import datetime, timedelta - -# first load the environment variables -load_dotenv() - -DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") -GUILD_IDS = [int(i) for i in os.environ.get("GUILD_LIST").split(" ")] - -PAYPAL_CLIENT_ID = os.getenv("PAYPAL_CLIENT_ID") -PAYPAL_CLIENT_SECRET = os.getenv("PAYPAL_CLIENT_SECRET") -PAYPAL_ENDPOINT = "https://api-m.paypal.com" -PAYPAL_TOKEN = 0 - -RESOURCE_LIST = [str(i) for i in os.environ.get("RESOURCE_LIST").split(" ")] -RESOURCE_ID_LIST = [i.split(":")[0] for i in RESOURCE_LIST] -RESOURCE_ROLE_LIST = [i.split(":")[1] for i in RESOURCE_LIST] - -ADMIN_ID_LIST=[int(i) for i in os.environ.get("ADMIN_ID_LIST").split(" ")] - -DEBUG = False -APPEAR_OFFLINE = True - -CHECK_PREVIOUSLY_VERIFIED = False -emails_verified = [] -resource_ids_verified = [] - -# init discord client -client = discord.Client(intents=discord.Intents.all()) -bot = Bot("!") - -# declare the discord slash commands through the client. -slash = SlashCommand(client, sync_commands=True) - -# --- functions --- - -# this formats a date for submitting to the PayPal API endpoint -async def format_date(date): - d = date.strftime('%Y-%m-%dT%H:%M:%SZ') - return d - -# this is for debugging / viewing reponses from paypal api -# def display_response(response): -# print('response:', response) -# print('url:', response.url) -# print('text:', response.text) - -# # this is for debugging / viewing data from paypal api -# def display_data(data): -# for key, value in data.items(): -# if key == 'scope': -# for item in value.split(' '): -# print(key, '=', item) -# else: -# print(key, '=', value) - -# read in any previously verified emails from file -async def read_in_emails(): - if CHECK_PREVIOUSLY_VERIFIED: - global emails_verified - try: - with open ('verified_emails', 'rb') as fp: - emails_verified = pickle.load(fp) - except FileNotFoundError: - pass - global resource_ids_verified - try: - with open ('verified_resource_ids', 'rb') as fp: - resource_ids_verified = pickle.load(fp) - except FileNotFoundError: - pass - -# write out any previously verified emails to file -async def write_out_emails(): - if CHECK_PREVIOUSLY_VERIFIED: - global emails_verified - with open('verified_emails', 'wb') as fp: - pickle.dump(emails_verified, fp) - global resource_ids_verified - with open('verified_resource_ids', 'wb') as fr: - pickle.dump(resource_ids_verified, fr) - -# check if an email has been previously verified -async def has_previously_verified(email, resource_id): - if CHECK_PREVIOUSLY_VERIFIED: - global emails_verified - try: - index = emails_verified.index(email) - resources_verified = resource_ids_verified[index] - for i in resources_verified.split(":"): - if resource_id == i: - return True - except ValueError: - pass - return False - -# add email and resource id list to verified -async def add_previously_verified(email, resource_id): - if CHECK_PREVIOUSLY_VERIFIED: - global emails_verified - index = 0 - try: - index = emails_verified.index(email) - except ValueError: - pass - global resource_ids_verified - # get list of previously verified resource ids - verified_ids = resource_ids_verified[index] - verified_ids = verified_ids + ":" + resource_id - resource_ids_verified[index] = verified_ids - -# this gets an oauth token from the paypal api -async def get_token(): - url = PAYPAL_ENDPOINT + '/v1/oauth2/token' - - headers = { - "Accept": "application/json", - "Accept-Language": "en_US", - } - - payload = { - "grant_type": "client_credentials" - } - - response = requests.post(url, auth=(PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET), data=payload) - data = response.json() - - # keep the token alive - token_expire = int(data['expires_in']) - 100 - t = Timer(token_expire, get_token) - t.daemon = True - t.start() - - logging.info(f"Got new access token.") - return data['access_token'] - -# this gets a list of transactions from the paypal api ranging from start_date to end_date -async def get_transactions(start_date, end_date): - url = PAYPAL_ENDPOINT + "/v1/reporting/transactions" - - payload={} - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {PAYPAL_TOKEN}" - } - - payload = { - 'start_date': f'{await format_date(start_date)}', - 'end_date': f'{await format_date(end_date)}', - 'fields': 'all' - } - - response = requests.get(url, headers=headers, params=payload) - - data = response.text - return data - -# this is a discord bot command to add a role to a user -@bot.command(pass_context=True) -async def addrole(ctx, role): - member = ctx.author - role = get(member.guild.roles, name=role) - await member.add_roles(role) - -# send a direct message to a list of admions -@bot.command(pass_context=True) -async def dm_admins(ctx, message): - #user: discord.User - for user_id in ADMIN_ID_LIST: - user = ctx.author.guild.get_member(user_id) - await user.send(message) - -# this searches through all transactions to find matching emails -# if an email is found, it returns the resourceid from the transaction -# if an email is not found, it returns an empty list -async def find_resource_ids_from_email(email, transactions): - matching_ids = [] - try: - for transaction in transactions["transaction_details"]: - try: - purchase_custom_field = transaction['transaction_info']['custom_field'] - purchase_email = transaction['payer_info']['email_address'] - # if purchase_email == 'test@example.com': - # print(purchase_custom_field) - # print(purchase_email) - if purchase_email.lower() == email.lower(): - s = purchase_custom_field.split('|') - s = s[len(s)-1] - # only add matching resource id to list if it hasnt been verified yet - if not await has_previously_verified(email, s): - matching_ids.append(s) # add the matching spigot resource id) - except KeyError: - pass - except KeyError: - pass - return matching_ids - -# discord event that fires when the bot is ready and listening -@client.event -async def on_ready(): - #set the logging config - logging.basicConfig(handlers=[logging.FileHandler('verifybot.log', 'a+', 'utf-8')], level=logging.INFO, format='%(asctime)s: %(message)s') - - if APPEAR_OFFLINE: - await client.change_presence(status=discord.Status.offline) - - # get the oauth token needed for paypal requests - global PAYPAL_TOKEN - PAYPAL_TOKEN = await get_token() - - # read in any previously verified emails - await read_in_emails() - - print("Ready!") - - -# defines a new 'slash command' in discord and what options to show to user for params -@slash.slash(name="paypal", - description="Verify your paypal purchase.", - options=[ - create_option( - name="email", - description="Verify your purchase via your paypal email.", - option_type=3, - required=True - )], - guild_ids=GUILD_IDS) -async def _verifypurchase(ctx, email: str): # Defines a new "context" (ctx) command called "paypal." - - logging.info(f"{ctx.author.name} ran command '/paypal {email}'") - - available_roles = RESOURCE_ROLE_LIST.copy() - # first check that the user doesn't already have the roles - #role_count = 0 - for role_element in RESOURCE_ROLE_LIST: - role = discord.utils.find(lambda r: r.name == role_element, ctx.author.guild.roles) - if role in ctx.author.roles: - available_roles.remove(role_element) - #role_count = role_count + 1 - if len(available_roles) == 0: - await ctx.send(f"You have already verified your purchase(s)!", hidden=True) - logging.info(f"{ctx.author.name} already had all verified roles.") - return - - # get current timestamp in UTC - end_date = datetime.today() - - await ctx.defer(hidden=True) - - roles_given = [] - # loop through purchases until a value is found or count == 36 (36 months is max for how far paypal api can go back) - count = 0 - success = False - while(len(available_roles) !=0 and count < 36): - - #search through purchases on 30 day intervals (paypal api has a max of 31 days) - start_date = end_date - timedelta(days=30) - transactions = json.loads(await get_transactions(start_date, end_date)) - - resource_ids = await find_resource_ids_from_email(email, transactions) - - # for all found resourceids in the paypal transactions - for id in resource_ids: - try: - # get index of id in main resource id list - index = RESOURCE_ID_LIST.index(id) - # get the corresponding resource role associated with that resource id - role = RESOURCE_ROLE_LIST[index] - available_roles.remove(role) - roles_given.append(role) - success = True; - # add the email to previously verified emails (with the resource id) - await add_previously_verified(email, id) - # add the configured discord role to the user who ran the command - await addrole(ctx, role) - logging.info(f"{ctx.author.name} given role: "+role) - except ValueError: - pass - - # make new end_date the old start_date for next while iteration - end_date = start_date - count = count + 1 - - if success: - await ctx.send(f"Successfully verified PayPal purchase!", hidden=True) - await dm_admins(ctx, "{} successfully verified a purchase with email: ".format(ctx.author.mention)+f"{email}. Given roles: {roles_given}") - logging.info(f"{ctx.author.name} successfully verified their purchase") - # write verified emails and resource ids out to files - await write_out_emails() - else: - await ctx.send("Failed to verify PayPal purchase.", hidden=True) - await dm_admins(ctx, "{} failed to verify a purchase with email: ".format(ctx.author.mention)+f"{email}") - logging.info(f"{ctx.author.name} failed to verify their purchase") - -# run the discord client with the discord token -client.run(DISCORD_TOKEN) \ No newline at end of file