diff --git a/.example.env b/.example.env new file mode 100644 index 0000000..3b2cc6d --- /dev/null +++ b/.example.env @@ -0,0 +1,7 @@ +DISCORD_TOKEN="DISCORT_BOT_TOKEN" +CHANNEL_ID="DISCORD_CHANNEL_ID" +GITHUB_TOKEN="GITHUB_TOKEN_TO_PUSH_TO_MAIN" +REPO_OWNER="netz39" +REPO_NAME="wwww.netz39.de" +BASE_BRANCH="main" +MAINTAINERS='[DISCORD_IDS_OF_BRANCH_MAINTAINERS]' \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..d1457a5 --- /dev/null +++ b/bot.py @@ -0,0 +1,173 @@ +import json +import re +import discord +from discord.ext import commands +from dotenv import load_dotenv +from os import getenv +from github_connector import create_or_update_file, delete_file + + +load_dotenv("./.env") + +CHANNEL_ID = int(getenv("CHANNEL_ID")) +MAINTAINERS = json.loads(getenv("MAINTAINERS")) +DISCORD_TOKEN = getenv("DISCORD_TOKEN") + +print(MAINTAINERS) + +# Intents are required to listen to events like messages +intents = discord.Intents.default() +intents.message_content = True +intents.members = True +intents.reactions = True +intents.dm_messages = True + +# Create an instance of the Bot +bot = commands.Bot(command_prefix="!", intents=intents) + + +def getFileName(event): + return f"{event.start_time.strftime("%Y-%m-%d")}_{event.name}" + + +def getFilePath(event): + return f"_events/{event.start_time.strftime("%Y")}/discord-event-{event.id}.md" + + +def getEventYML(event): + return f'# event imported from discord manual changes may be overwritten\n---\n \nlayout: event\ntitle: "{event.name}"\nauthor: "Netz39 e.V." \nevent:\n start: {event.start_time.strftime("%Y-%m-%d %H:%M:%S")} \n end: {event.end_time.strftime("%Y-%m-%d %H:%M:%S")} \n organizer: "Netz39 Team <kontakt@netz39.de>" \n location: "Leibnizstr. 18, 39104 Magdeburg"\n---' + + +def addMaintainer(maintainer): + MAINTAINERS.append(maintainer) + with open(".env", "r") as fileR: + # read a list of lines into data + data = fileR.readlines() + data[6] = f"MAINTAINERS='{json.dumps(MAINTAINERS)}'" + with open(".env", "w") as fileW: + fileW.writelines(data) + + +async def handleEvent(event): + eventYML = getEventYML(event) + if "netz39" in event.location.lower() or ( + "leibniz" in event.location.lower() and "18" in event.location + ): + if event.creator_id in MAINTAINERS: + create_or_update_file( + getFilePath(event), + eventYML, + f"new event: {getFileName(event)}", + ) + channel = bot.get_channel(CHANNEL_ID) + await channel.send( + f"📅 New event '{event.name}' on {event.start_time.strftime("%Y-%m-%d")}." + ) + else: + eventJSON = json.dumps( + { + "yml": eventYML, + "path": getFilePath(event), + "fileName": getFileName(event), + "name": event.name, + "date": event.start_time.strftime("%Y-%m-%d"), + }, + indent=2, + ) + for maintainer in MAINTAINERS: + user = await bot.fetch_user(event.creator_id) + await sendDm( + maintainer, + f"{user.name} added a new Event on {event.start_time.strftime("%Y")}: {event.name}. Like this message to approve.\n\n\n```json\n{eventJSON}```", + ) + + +# Event: When the bot is ready +@bot.event +async def on_ready(): + print(f"Logged in as {discord.user}") + + +# Command: !hello +@bot.command() +async def hello(ctx): + await ctx.send( + "Hello, I am a bot to add events from discord to the Netz39 calendar on the website!" + ) + + +@bot.event +async def on_ready(): + print(f"Logged in as {bot.user}") + channel = bot.get_channel(CHANNEL_ID) + await channel.send("Hello everyone! I'm online and ready to go! 📅") + + +@bot.event +async def on_scheduled_event_update(event): + await handleEvent(event) + + +@bot.event +async def on_scheduled_event_create(event): + await handleEvent(event) + + +async def sendDm(userID, message): + user = await bot.fetch_user(userID) + await user.send(message) + + +@bot.event +async def on_reaction_add(reaction, user): + if not user.id in MAINTAINERS: + return # Ignore non-maintainers + if user.bot: + return # Ignore bot reactions + + if ( + isinstance(reaction.message.channel, discord.DMChannel) + and reaction.message.author.bot + ): # Check if it's a DM and from the bot + if reaction.emoji == "👍": + messageJSON = json.loads( + re.search( + "(?s)(?<=```json).*?(?=```)", reaction.message.content + ).group() + ) + create_or_update_file( + messageJSON["path"], + messageJSON["yml"], + f"new event: {messageJSON["fileName"]}", + ) + channel = bot.get_channel(CHANNEL_ID) + await channel.send( + f"📅 New event '{messageJSON["name"]}' on {messageJSON["date"]}." + ) + + +@bot.event +async def on_message(message): + if isinstance(message.channel, discord.DMChannel): + global MAINTAINERS + author = message.author + if "add maintainer" in message.content.lower() and author.id in MAINTAINERS: + newMaintainer = message.content.lower().replace("add maintainer ", "") + if not newMaintainer in MAINTAINERS: + addMaintainer(int(newMaintainer)) + await sendDm( + newMaintainer, + "You are now a Maintainer for posthorn! Create an event located at 'Netz39 e.V.' and it will be added to the calendar.", + ) + await message.channel.send("Maintainer added!") + await bot.process_commands(message) + + +@bot.event +async def on_scheduled_event_delete(event): + delete_file(getFilePath(event)) + channel = bot.get_channel(CHANNEL_ID) + await channel.send(f"❌ Event has been canceled: {event.name}") + + +bot.run(DISCORD_TOKEN) diff --git a/github_connector.py b/github_connector.py new file mode 100644 index 0000000..9160a4b --- /dev/null +++ b/github_connector.py @@ -0,0 +1,71 @@ +import requests +import json +from dotenv import load_dotenv +from os import getenv +import base64 + +load_dotenv() + +# load env vars +REPO_OWNER = getenv("REPO_OWNER") +REPO_NAME = getenv("REPO_NAME") +BASE_BRANCH = getenv("BASE_BRANCH") +GITHUB_TOKEN = getenv("GITHUB_TOKEN") + +# GitHub API URL +API_URL = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}" + +# Headers for authentication +HEADERS = { + "Authorization": f"token {GITHUB_TOKEN}", + "Accept": "application/vnd.github.v3+json", +} + + +def get_file_sha(file_path): + url = f"{API_URL}/contents/{file_path}?ref={BASE_BRANCH}" + response = requests.get(url, headers=HEADERS) + + if response.status_code == 200: + return response.json()["sha"] + else: + raise Exception(f"Error getting file SHA: {response.json()}") + + +def delete_file(file_path): + url = f"{API_URL}/contents/{file_path}" + data = { + "message": f"Deleting file: {file_path}", + "sha": get_file_sha(file_path), + "branch": BASE_BRANCH, + } + response = requests.delete(url, headers=HEADERS, json=data) + + if not response.status_code == 200 and not response.status_code == 404: + raise Exception(f"Error deleting file: {response.json()}") + + +def create_or_update_file(file_path, content, commit_message): + url = f"{API_URL}/contents/{file_path}" + b = base64.b64encode(bytes(content, "utf-8")) + base64_str = b.decode("utf-8") + + # Check if file exists + response = requests.get(url, headers=HEADERS) + if response.status_code == 200: + sha = response.json()["sha"] + else: + sha = None + + data = { + "message": commit_message, + "content": base64_str, + "branch": BASE_BRANCH, + } + + if sha: + data["sha"] = sha # If file exists, update it + + response = requests.put(url, headers=HEADERS, json=data) + if not response.status_code in [200, 201]: + raise Exception(f"Error committing file: {response.json()}") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..44381ad --- /dev/null +++ b/requirements.txt @@ -0,0 +1,24 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.11.14 +aiosignal==1.3.2 +attrs==25.3.0 +certifi==2025.1.31 +cffi==1.17.1 +charset-normalizer==3.4.1 +cryptography==44.0.2 +Deprecated==1.2.18 +discord.py==2.5.2 +frozenlist==1.5.0 +idna==3.10 +multidict==6.2.0 +propcache==0.3.1 +pycparser==2.22 +PyGithub==2.6.1 +PyJWT==2.10.1 +PyNaCl==1.5.0 +python-dotenv==1.1.0 +requests==2.32.3 +typing_extensions==4.13.0 +urllib3==2.3.0 +wrapt==1.17.2 +yarl==1.18.3