From ddc0deccefefb9d4b68ef8f2fac3cf2a010bc299 Mon Sep 17 00:00:00 2001 From: Nathan Woodburn Date: Wed, 8 Nov 2023 16:29:54 +1100 Subject: [PATCH] feat: Add initial files --- .gitea/workflows/build.yml | 41 ++++++++++ .gitignore | 8 ++ Dockerfile | 17 ++++ bot.py | 159 +++++++++++++++++++++++++++++++++++++ faucet.py | 24 ++++++ requirements.txt | 5 ++ shaker.py | 64 +++++++++++++++ 7 files changed, 318 insertions(+) create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 bot.py create mode 100644 faucet.py create mode 100644 requirements.txt create mode 100644 shaker.py diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..47316ce --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,41 @@ +name: Build Docker +run-name: Build Docker Images +on: + push: + +jobs: + Build Image: + runs-on: [ubuntu-latest, amd] + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Install Docker + run : | + apt-get install ca-certificates curl gnupg + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + apt-get update + apt-get install docker-ce-cli -y + - name: Build Docker image + run : | + echo "${{ secrets.DOCKERGIT_TOKEN }}" | docker login git.woodburn.au -u nathanwoodburn --password-stdin + echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" + tag=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}} + tag=${tag//\//-} + tag_num=${GITHUB_RUN_NUMBER} + echo "tag_num=$tag_num" + + if [[ "$tag" == "main" ]]; then + tag="latest" + else + tag_num="${tag}-${tag_num}" + fi + + + docker build -t shaker-bot:$tag_num . + docker tag shaker-bot:$tag_num git.woodburn.au/nathanwoodburn/shaker-bot:$tag_num + docker push git.woodburn.au/nathanwoodburn/shaker-bot:$tag_num + docker tag shaker-bot:$tag_num git.woodburn.au/nathanwoodburn/shaker-bot:$tag + docker push git.woodburn.au/nathanwoodburn/shaker-bot:$tag \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24389d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ + +.env + +__pycache__/ + +roles.json + +faucet.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b2bc0da --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM --platform=$BUILDPLATFORM python:3.10-alpine AS builder + +WORKDIR /app + +COPY requirements.txt /app +RUN --mount=type=cache,target=/root/.cache/pip \ + pip3 install -r requirements.txt + +COPY . /app + +# Add mount point for data volume +VOLUME /data + +ENTRYPOINT ["python3"] +CMD ["bot.py"] + +FROM builder as dev-envs \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..5e623e0 --- /dev/null +++ b/bot.py @@ -0,0 +1,159 @@ +import base64 +import os +from dotenv import load_dotenv +import discord +from discord import app_commands +import json +from faucet import send_domain +import dns.resolver +import dns.exception +import dns.message +import shaker +import re + + +load_dotenv() +TOKEN = os.getenv('DISCORD_TOKEN') +ADMINID = 0 + +intents = discord.Intents.default() +intents.members = True +intents.guilds = True +client = discord.Client(intents=intents) +tree = app_commands.CommandTree(client) + +# Commands +@tree.command(name="faucet", description="Get a free domain") +async def faucet(ctx, email:str): + # Check if a DM + if ctx.guild is None: + await ctx.response.send_message("You can not claim from the faucet in DMs") + return + + + roles = {} + if os.path.exists('/data/faucet.json'): + with open('/data/faucet.json', 'r') as f: + roles = json.load(f) + if str(ctx.guild.id) in roles: + if roles[str(ctx.guild.id)] in [role.id for role in ctx.user.roles]: + await ctx.response.send_message("I'll send you a DM when your domain has been sent",ephemeral=True) + await send_domain(ctx.user, email) + return + await ctx.response.send_message("You can't claim from the faucet",ephemeral=True) + +@tree.command(name="faucet-role", description="Change the role that can use the faucet") +async def faucetrole(ctx,role:discord.Role): + if ctx.user.id != ADMINID: + await ctx.response.send_message("You don't have permission to do that",ephemeral=True) + return + await ctx.response.send_message("Faucet role set to " + role.name + " for server " + ctx.guild.name,ephemeral=True) + roles = {} + if os.path.exists('/data/faucet.json'): + with open('/data/faucet.json', 'r') as f: + roles = json.load(f) + + roles[str(ctx.guild.id)] = role.id + with open('/data/faucet.json', 'w') as f: + json.dump(roles, f) + +@tree.command(name="setverifiedrole", description="Set the role that verified users get") +async def setverifiedrole(ctx,role:discord.Role): + # Check user has manage guild permission + if not ctx.user.guild_permissions.manage_guild: + await ctx.response.send_message("You don't have permission to do that",ephemeral=True) + return + # Verify bot can manage roles + if not ctx.guild.me.guild_permissions.manage_roles: + await ctx.response.send_message("I don't have permission to do that",ephemeral=True) + return + # Verify I can manage the role + if not ctx.guild.me.top_role > role: + await ctx.response.send_message("I don't have permission to do that",ephemeral=True) + return + + if not os.path.exists('/data/roles.json'): + with open('/data/roles.json', 'w') as f: + json.dump({}, f) + + with open('/data/roles.json', 'r') as f: + roles = json.load(f) + + roles[str(ctx.guild.id)] = role.id + with open('/data/roles.json', 'w') as f: + json.dump(roles, f) + + await ctx.response.send_message("Verified role set to " + role.name + " for server " + ctx.guild.name,ephemeral=True) + +@tree.command(name="verify", description="Verifies your ownership of a Handshake name and sets your nickname.") +async def verify(ctx, domain:str): + name_idna = domain.lower().strip().rstrip("/").encode("idna") + name_ascii = name_idna.decode("ascii") + + parts = name_ascii.split(".") + + for part in parts: + if not re.match(r'[A-Za-z0-9-_]+$', part): + return await ctx.response.send_message("Invalid domain",ephemeral=True) + + + try: + name_rendered = name_idna.decode("idna") + except UnicodeError: # don't render invalid punycode + name_rendered = name_ascii + + if shaker.check_name(ctx.user.id, name_ascii): + try: + await ctx.user.edit(nick=name_rendered + "/") + # Set role + await shaker.handle_role(ctx.user, True) + return await ctx.response.send_message("Your nickname has been set to " + name_rendered + "/",ephemeral=True) + except discord.errors.Forbidden: + return await ctx.response.send_message("I don't have permission to do that",ephemeral=True) + + records = [{ + "type": 'TXT', + "host": ".".join(["_shaker", "_auth"] + parts[:-1]), + "value": str(ctx.user.id), + "ttl": 60, + }] + + records = json.dumps(records) + records = records.encode("utf-8") + records = base64.b64encode(records) + records = records.decode("utf-8") + + message = f"To verify that you own `{name_rendered}/` please create a TXT record located at `_shaker._auth.{name_ascii}` with the following data: `{ctx.user.id}`.\n\n" + message += f"If you use Namebase, you can do this automatically by visiting the following link:\n" + message += f"\n\n" + message += f"Once the record is set (this may take a few minutes) you can run this command again or manually set your nickname to `{name_rendered}/`." + + await ctx.response.send_message(message,ephemeral=True) + + + +# When the bot is ready +@client.event +async def on_ready(): + global ADMINID + ADMINID = client.application.owner.id + await tree.sync() + +# When a member updates their nickname +@client.event +async def on_member_update(before, after): + await shaker.check_member(after) + +@client.event +async def on_member_join(member) -> None: + await shaker.check_member(member) + +@client.event +async def on_message(message): + if message.author == client.user: + return + if not message.guild: + await message.channel.send('Invite this bot into your server by using this link:\nhttps://discord.com/api/oauth2/authorize?client_id=1073940877984153692&permissions=402653184&scope=bot') + + +client.run(TOKEN) \ No newline at end of file diff --git a/faucet.py b/faucet.py new file mode 100644 index 0000000..2750ab1 --- /dev/null +++ b/faucet.py @@ -0,0 +1,24 @@ +import os +from dotenv import load_dotenv +import discord +from discord import app_commands +import json +from email_validator import validate_email, EmailNotValidError +import requests + +async def send_domain(user, email): + try: + emailinfo = validate_email(email, check_deliverability=False) + email = emailinfo.normalized + except EmailNotValidError as e: + await user.send("Your email is invalid") + return + + response = requests.post("https://faucet.woodburn.au/api?email=" + email+"&name="+user.name + "&key=" + os.getenv('FAUCET_KEY')) + response = response.json() + if response['success']: + await user.send("Congratulations! We've sent you a domain to your email") + else: + await user.send("Sorry, something went wrong. Please try again later") + await user.send(response['error']) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8f241d8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +python-dotenv +requests +email-validator +py3dns +discord.py \ No newline at end of file diff --git a/shaker.py b/shaker.py new file mode 100644 index 0000000..6522903 --- /dev/null +++ b/shaker.py @@ -0,0 +1,64 @@ +import os +from dotenv import load_dotenv +import dns.resolver +import dns.exception +import dns.message +import discord +import json + +load_dotenv() + +resolver = dns.resolver.Resolver() +serverIP = os.getenv('DNS_SERVER') +resolver.nameservers = [serverIP] +resolver.port = int(os.getenv('DNS_PORT')) + + +def check_name(user_id: int, name: str) -> bool: + try: + answer = resolver.resolve('_shaker._auth.' + name, 'TXT') + for rrset in answer.response.answer: + parts = rrset.to_text().split(" ") + if str(user_id) in parts[-1]: + return True + except dns.exception.DNSException as e: + print("DNS Exception") + print(e) + pass + return False + +async def handle_role(member: discord.Member, shouldHaveRole: bool): + with open('/data/roles.json', 'r') as f: + roles = json.load(f) + + key = str(member.guild.id) + + if not key in roles: + return + + role_id = roles[key] + + if role_id: + guild = member.guild + role = guild.get_role(role_id) + if role and shouldHaveRole and not role in member.roles: + await member.add_roles(role) + elif role and not shouldHaveRole and role in member.roles: + await member.remove_roles(role) + + +async def check_member(member: discord.Member) -> bool: + if member.display_name[-1] != "/": + await handle_role(member, False) + return + + if check_name(member.id, member.display_name[0:-1]): + await handle_role(member, True) + return True + + try: + await member.edit(nick=member.display_name[0:-1]) + except Exception as e: + print(e) + await handle_role(member, False) + return False \ No newline at end of file