Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions discord/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ __pycache__
registered_log.txt
schedule.json
pretix_cache.json
.vscode/
2 changes: 2 additions & 0 deletions discord/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ python-dotenv = "*"
yarl = "*"
pydantic = "*"
unidecode = "*"
requests = "*"
fastapi = {extras = ["standard"], version = "*"}

[dev-packages]
black = "*"
Expand Down
3,357 changes: 1,940 additions & 1,417 deletions discord/Pipfile.lock

Large diffs are not rendered by default.

Empty file.
33 changes: 33 additions & 0 deletions discord/PyLadiesBot/api_server/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import os
import secrets
from pathlib import Path

from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, status
from helpers import _setup_logging
from oneoff import assign_volunteer

from PyLadiesBot.api_server.models import DiscordUser, Result

load_dotenv(Path(__file__).resolve().parent.parent.parent / ".secrets")
BOT_API_TOKEN = os.getenv("BOT_API_TOKEN")

_setup_logging()
app = FastAPI()


@app.post("/events/volunteer-approved")
async def volunteer_approved(body: DiscordUser) -> Result:
is_authenticated = secrets.compare_digest(
body.token,
BOT_API_TOKEN,
)
if not is_authenticated:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)

await assign_volunteer(body.id)
return Result(is_success=True)
13 changes: 13 additions & 0 deletions discord/PyLadiesBot/api_server/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from pydantic import BaseModel


class Result(BaseModel):
is_success: bool


class Token(BaseModel):
token: str


class DiscordUser(Token):
id: int
25 changes: 4 additions & 21 deletions discord/PyLadiesBot/bot.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
import asyncio
import logging
import os
import sys
from pathlib import Path

import discord
from discord.ext import commands
from dotenv import load_dotenv

import configuration
from cogs.ping import Ping
from cogs.pretix_donations import PretixDonations
from discord.ext import commands
from dotenv import load_dotenv
from helpers import _setup_logging
from program_notifications.cog import ProgramNotificationsCog
from registration.cog import RegistrationCog

load_dotenv(Path(__file__).resolve().parent.parent / ".secrets")
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")


_logger = logging.getLogger("bot")


Expand Down Expand Up @@ -44,22 +43,6 @@ async def load_extension(self, name: str, *, package: str | None = None) -> None
_logger.info("Successfully loaded extension %r (package=%r)", name, package)


def _setup_logging() -> None:
"""Set up a basic logging configuration."""
config = configuration.Config()

# Create a stream handler that logs to stdout (12-factor app)
stream_handler = logging.StreamHandler(stream=sys.stdout)
stream_handler.setLevel(config.LOG_LEVEL)
formatter = logging.Formatter(fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
stream_handler.setFormatter(formatter)

# Configure the root logger with the stream handler and log level
root_logger = logging.getLogger()
root_logger.addHandler(stream_handler)
root_logger.setLevel(config.LOG_LEVEL)


def _get_intents() -> discord.Intents:
"""Get the desired intents for the bot."""
intents = discord.Intents.all()
Expand Down
4 changes: 3 additions & 1 deletion discord/PyLadiesBot/cogs/ping.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging

from discord.ext import commands
from helpers import DiscordLogger

_logger = logging.getLogger(f"bot.{__name__}")

Expand All @@ -9,8 +10,9 @@ class Ping(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot: commands.Bot = bot
_logger.info("Cog 'Ping' has been initialized")
self.discord_logger = DiscordLogger(__name__, self.bot)

@commands.hybrid_command(name="ping", description="Ping the bot")
async def ping_command(self, ctx: commands.Context) -> None:
_logger.debug("The 'ping' command has been triggered!")
await self.discord_logger.info("The 'ping' command has been triggered!")
await ctx.send("Pong!")
4 changes: 4 additions & 0 deletions discord/PyLadiesBot/config.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[discord_server]
GUILD = 1000000000000000 # Needed for one-off commands (API) with no message context

[roles]
ORGANISERS = 1253767144286588990
VOLUNTEERS = 1253767144286588989
Expand Down Expand Up @@ -42,6 +45,7 @@ DONATIONS_CHANNEL_ID = 1314028552223457471

[logging]
LOG_LEVEL = "INFO"
channel_id = "TO SET"

[program_notifications]
# UTC offset in hours (e.g. 2 for CEST)
Expand Down
10 changes: 9 additions & 1 deletion discord/PyLadiesBot/configuration.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import logging
import sys
import tomllib
from datetime import datetime, timedelta, timezone
from pathlib import Path

import tomllib

_logger = logging.getLogger(f"bot.{__name__}")


Expand Down Expand Up @@ -33,6 +34,12 @@ def __init__(self):
sys.exit(-1)

try:
# Discord Server
self.GUILD_ID = int(config["discord_server"]["GUILD"])

# ROLES
self.ROLES_VOLUNTEERS = int(config["roles"]["VOLUNTEERS"])

# Registration
self.REG_CHANNEL_ID = int(config["registration"]["REG_CHANNEL_ID"])
self.REG_HELP_CHANNEL_ID = int(config["registration"]["REG_HELP_CHANNEL_ID"])
Expand Down Expand Up @@ -76,6 +83,7 @@ def __init__(self):

# Logging
self.LOG_LEVEL = config.get("logging", {}).get("LOG_LEVEL", "INFO")
self.log_channel = config["logging"]["channel_id"]

except KeyError:
_logger.exception(
Expand Down
96 changes: 96 additions & 0 deletions discord/PyLadiesBot/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import logging
import sys
import textwrap

import configuration
from configuration import Config
from discord import Message
from discord.abc import Messageable
from discord.ext.commands import Bot

config = Config()


async def safe_send_message(target: Messageable, text: str) -> list[Message]:
"""Safely send a message to the given messageable target.
The utility of this is function, is that the message will be split into multiple parts
if its too long.
"""
messages = []
for part in textwrap.wrap(
text,
width=2000,
expand_tabs=False,
replace_whitespace=False,
drop_whitespace=False,
):
message = await target.send(part)
messages.append(message)
return messages


class DiscordLogger:
"""Wrapper for the configured project logger, that also sends the same logs to discord.

Use any of the logging level methods, just like in the standard library on the
instantiated object.

Automatically prefixes `bot.` to the name provided for the logger.

Requires a bot object to be instantiated and passed into it.

For example:
```python
discord_logger = DiscordLogger(__name__, self.bot)
discord_logger.info("The 'ping' command has been triggered!")
discord_logger.error("A problem has occurred!")
```
"""

def __init__(self, name: str, bot: Bot):
self.name = f"bot.{name}"
self.bot = bot
self.logger = logging.getLogger(self.name)

def __getattr__(self, method_name: str):
logging_types = {
"debug",
"info",
"warning",
"warn",
"error",
"exception",
"critical",
"fatal",
}
if method_name not in logging_types:
raise AttributeError(f"Logging type must be one of {logging_types}")

def _log(message: str):

async def inner():
logging_method = getattr(self.logger, method_name)
logging_method(message)
channel = self.bot.get_channel(config.log_channel)
prefix = f"{self.name} - {method_name.upper()} - "
await safe_send_message(channel, f"{prefix}{message}")

return inner()

return _log


def _setup_logging() -> None:
"""Set up a basic logging configuration."""
config = configuration.Config()

# Create a stream handler that logs to stdout (12-factor app)
stream_handler = logging.StreamHandler(stream=sys.stdout)
stream_handler.setLevel(config.LOG_LEVEL)
formatter = logging.Formatter(fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
stream_handler.setFormatter(formatter)

# Configure the root logger with the stream handler and log level
root_logger = logging.getLogger()
root_logger.addHandler(stream_handler)
root_logger.setLevel(config.LOG_LEVEL)
78 changes: 78 additions & 0 deletions discord/PyLadiesBot/oneoff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""One-off bot commands.

Appropriate for triggering bot actions based on API requests to this bot system,
such as events from a web portal.
"""

import os
from enum import Enum
from pathlib import Path

from bot import _get_intents
from configuration import Config
from discord.ext import commands
from discord.utils import get
from dotenv import load_dotenv
from helpers import DiscordLogger

load_dotenv(Path(__file__).resolve().parent.parent / ".secrets")
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")

config = Config()


class Action(Enum):
ASSIGN_VOLUNTEER = 0


class Bot(commands.Bot):

def __init__(self, action: Action, discord_id: int):
intents = _get_intents()
# Not sure how to do class based oneoff connections
# May just use non way here: https://discordpy.readthedocs.io/en/stable/quickstart.html
super().__init__(command_prefix="", intents=intents)
self.channels = {}
self.action = action
self.payload_discord_id = discord_id
self.discord_logger = DiscordLogger("oneoff", self)

@property
def method_mapping(self) -> dict:
return {Action.ASSIGN_VOLUNTEER.value: self.assign_volunteer}

async def on_ready(self):
self.guild = self.get_guild(config.GUILD_ID)
await self.discord_logger.info(
f"Executing one-off command {self.action.name} on discord ID {self.payload_discord_id} with user {self.user.name} (ID={self.user.id})"
)
if not self.guild:
_msg = f"No GUILD found for {config.GUILD_ID}!"
await self.discord_logger.critical(_msg)
raise AssertionError(_msg)
await self.discord_logger.info(f"Using GUILD {self.guild}")
await self.method_mapping[self.action.value](self.payload_discord_id)
await self.close()

async def assign_volunteer(self, discord_id: int):
# TODO Make DRY
member = self.guild.get_member(discord_id)
if not member:
_msg = f"Member not found for ID {discord_id}"
await self.discord_logger.critical(_msg)
raise AssertionError(_msg)
role = get(self.guild.roles, id=config.ROLES_VOLUNTEERS)
if not role:
_msg = f"Role not found for ID {discord_id}"
await self.discord_logger.critical(_msg)
raise AssertionError(_msg)
await member.add_roles(role)
await self.discord_logger.info(
f"Successfully assigned the {role.mention} role to {member.mention}."
)


async def assign_volunteer(discord_user_id: int):
bot = Bot(action=Action.ASSIGN_VOLUNTEER, discord_id=discord_user_id)
async with bot:
await bot.start(DISCORD_BOT_TOKEN)
5 changes: 5 additions & 0 deletions discord/PyLadiesBot/staging-config.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
[discord_server]
GUILD = 1000000000000000 # Needed for one-off commands (API) with no message context


[roles]
ORGANISERS = 1249772565887582301
VOLUNTEERS = 1249772565887582300
Expand Down Expand Up @@ -38,6 +42,7 @@ PRETIX_CACHE_FILE = "pretix_cache.json"

[logging]
LOG_LEVEL = "INFO"
channel_id = "TO SET"

[program_notifications]
# UTC offset in hours (e.g. 2 for CEST)
Expand Down
4 changes: 4 additions & 0 deletions discord/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ The `main` method in `PyLadiesBot/bot.py` is the entry point for the bot. I't a
to start browsing the codebase. It requires a `.secrets` file in the root of the repository with
`DISCORD_BOT_TOKEN` and `PRETIX_TOKEN` environment variables.

The API server is used to send events __to__ the bot (such as events from the PyLadies portal).
To run it, run `fastapi dev PyLadiesBot/api_server/main.py`. It uses the same configuration and secrets as the main bot.

### Registration

At PyLadiesCon, we use [pretix](https://pretix.eu/about/en/) as our ticketing system.
Expand Down Expand Up @@ -138,6 +141,7 @@ Add `.secrets` file to the root of the repository with the following content:
```shell
DISCORD_BOT_TOKEN=<PyLadiesConTestBotToken>
PRETIX_TOKEN=<PretixStagingToken>
BOT_API_TOKEN=<BotAPIServerToken>
````
After you have added the `.secrets` file, you can run the bot with the following command:
```shell
Expand Down
Loading