Merge branch 'release/0.1.0'

This commit is contained in:
Jörn-Michael Miehe 2023-11-25 00:04:55 +01:00
commit 1b92529373
14 changed files with 1393 additions and 6 deletions

View file

@ -1,7 +1,7 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the // For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/python // README at: https://github.com/devcontainers/templates/tree/main/src/python
{ {
"name": "Python 3", "name": "LEVbot",
"build": { "build": {
"dockerfile": "Dockerfile", "dockerfile": "Dockerfile",
"context": "..", "context": "..",
@ -9,7 +9,7 @@
// Update 'VARIANT' to pick a Python version. // Update 'VARIANT' to pick a Python version.
// Append -bookworm, -bullseye or -buster to pin to an OS version. // Append -bookworm, -bullseye or -buster to pin to an OS version.
// Use -bookworm or -bullseye variants on arm64/Apple Silicon. // Use -bookworm or -bullseye variants on arm64/Apple Silicon.
"VARIANT": "3.12", "VARIANT": "3.11",
// Options // Options
"NODE_VERSION": "none" "NODE_VERSION": "none"
} }

View file

@ -1,4 +1,4 @@
[flake8] [flake8]
max-line-length = 80 max-line-length = 80
select = C,E,F,I,W,B,B950 select = C,E,F,I,W,B,B950
extend-ignore = E203, E501 extend-ignore = E203, E501, W503

2
.gitignore vendored
View file

@ -158,3 +158,5 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
lenaverse-bot.toml

19
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,19 @@
{
// Verwendet IntelliSense zum Ermitteln möglicher Attribute.
// Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen.
// Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Lenaverse Bot",
"type": "python",
"request": "launch",
"module": "lenaverse_bot.main",
"justMyCode": true,
"env": {
"LOG_LEVEL": "DEBUG",
"CONFIG_PATH": "lenaverse-bot.toml",
},
}
]
}

View file

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2023 Yavook!de Copyright (c) 2023 Lenaisten.de
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View file

@ -1,3 +1,8 @@
# vscode-python3 # lenaverse-bot
Use this template to jumpstart python development using Visual Studio Code! lenaverse-bot ist ein Discord-Bot, der den Lenaisten e.V. im LENAVERSE Discord vertritt.
Berechtigte Mitglieder können über den `/post`-Befehl Ankündigungen in ausgewählten Channels veröffentlichen.
Außerdem gibt es einige öffentliche Befehle, mit denen die Benutzer Aktuelles über den Verein erfahren können.
Allen Befehlen kann ein Präfix vorangestellt werden, sodass `/post` zB. als `/ev_post`.

View file

@ -0,0 +1,9 @@
import logging
import os
import discord
discord.utils.setup_logging()
_logger = logging.getLogger(__name__)
_logger.setLevel(os.getenv("LOG_LEVEL", logging.WARN))

25
lenaverse_bot/core/bot.py Normal file
View file

@ -0,0 +1,25 @@
import logging
import discord
from . import post, verein
_logger = logging.getLogger(__name__)
class LenaverseBot(discord.Client):
def __init__(self) -> None:
super().__init__(intents=discord.Intents.default())
self.tree = discord.app_commands.CommandTree(self)
commands = post.COMMANDS + verein.COMMANDS
for command in commands:
self.tree.add_command(command)
async def setup_hook(self):
await self.tree.sync()
_logger.info("Commands synced")
async def on_ready(self) -> None:
assert self.user is not None
_logger.info(f"{self.user.name} has connected to Discord!")

View file

@ -0,0 +1,91 @@
import os
import tomllib
from io import BytesIO
from typing import Annotated, Self
import aiohttp
import discord
from pydantic import BaseModel, StringConstraints
StrippedStr = Annotated[str, StringConstraints(strip_whitespace=True)]
class Post(BaseModel):
# users authorized to posts
users: list[int]
# where to put posts
channel: int
def get_channel(
self,
client: discord.Client,
) -> discord.Thread | discord.TextChannel:
"""
Zielkanal für Posts finden
"""
channel = client.get_channel(self.channel)
assert isinstance(channel, discord.Thread | discord.TextChannel)
return channel
class InfoCommand(BaseModel):
name: StrippedStr
description: StrippedStr = "..."
content: StrippedStr = ""
class FileCommand(InfoCommand):
file_url: str = ""
file_name: str = ""
@property
async def as_bytes(self) -> bytes | None:
async with aiohttp.ClientSession() as session:
async with session.get(self.file_url) as response:
if response.status != 200:
return None
return await response.read()
@property
async def as_discord_file(self) -> discord.File | None:
if (as_bytes := await self.as_bytes) is not None:
return discord.File(
fp=BytesIO(as_bytes),
filename=self.file_name,
)
class ClubInfo(BaseModel):
channels: list[int]
info: InfoCommand
vorstand: InfoCommand
linktree: InfoCommand
beitreten: InfoCommand
fest: InfoCommand
aktion: InfoCommand
class Config(BaseModel):
discord_token: str
command_prefix: str
command_failed: str
post: Post
ev_info: ClubInfo
@classmethod
def get(cls) -> Self:
cfg_path = os.getenv(
key="CONFIG_PATH",
default="/usr/local/etc/lenaverse-bot/lenaverse-bot.toml",
)
with open(cfg_path, "rb") as cfg_file:
return cls.model_validate(tomllib.load(cfg_file))
CONFIG = Config.get()

122
lenaverse_bot/core/post.py Normal file
View file

@ -0,0 +1,122 @@
import logging
from enum import Enum, auto
import discord
from discord import ui
from .config import CONFIG
_logger = logging.getLogger(__name__)
class Action(Enum):
"""
Was soll mit dem Post geschehen?
"""
PUBLISH = auto()
ABORT = auto()
TIMEOUT = auto()
class PostConfirm(ui.View):
"""
Buttons zum Veröffentlichen oder Verwerfen eines Posts
"""
action: Action = Action.TIMEOUT
async def __resolve(
self,
interaction: discord.Interaction,
msg: str,
action: Action,
) -> None:
await interaction.response.edit_message(content=msg, view=None)
self.action = action
self.stop()
@ui.button(label="Veröffentlichen", style=discord.ButtonStyle.success)
async def publish(self, interaction: discord.Interaction, button: ui.Button):
await self.__resolve(interaction, "Post wird veröffentlicht.", Action.PUBLISH)
@ui.button(label="Verwerfen", style=discord.ButtonStyle.danger)
async def abort(self, interaction: discord.Interaction, button: ui.Button):
await self.__resolve(interaction, "Post wird verworfen.", Action.ABORT)
class PostModal(ui.Modal, title="Post verfassen"):
"""
Eingabefeld zum Verfassen eines Postings
"""
content = ui.TextInput(
label="Inhalt",
style=discord.TextStyle.long,
placeholder="Post-Inhalt hier einfügen ...",
)
async def on_submit(self, interaction: discord.Interaction):
post_content = f"{self.content.value}\n> Gepostet von <@{interaction.user.id}>"
# Vorschau mit Buttons
view = PostConfirm()
await interaction.response.send_message(
content=f"## Post-Vorschau\n\n{post_content}",
view=view,
# nur für ausführenden User
ephemeral=True,
)
# warten auf User-Entscheidung
if await view.wait() is True:
# Entscheidungszeit abgelaufen
await interaction.delete_original_response()
await interaction.user.send("Zeit abgelaufen, Post verworfen")
_logger.info(
f"User {interaction.user.name}({interaction.user.id}) timeout during /post"
)
elif view.action is Action.PUBLISH:
# Post veröffentlichen
_logger.info(
f"User {interaction.user.name}({interaction.user.id}) published a /post"
)
target = CONFIG.post.get_channel(interaction.client)
await target.send(post_content)
elif view.action is Action.ABORT:
# Post abbrechen
_logger.info(
f"User {interaction.user.name}({interaction.user.id}) aborted a /post"
)
@discord.app_commands.command(name=CONFIG.command_prefix + "post")
async def post(interaction: discord.Interaction) -> None:
"""
Einen Post im Lenaisten-Bereich verfassen (nur für ausgewählte Mitglieder verfügbar)
"""
if interaction.user.id in CONFIG.post.users:
# Verfassen-Dialog anzeigen
_logger.debug(
f"User {interaction.user.name}({interaction.user.id}) started a /post"
)
await interaction.response.send_modal(PostModal())
else:
# Zugriff verweigern
_logger.info(
f"User {interaction.user.name}({interaction.user.id}) tried to /post"
)
await interaction.response.send_message(
content="Du bist nicht berechtigt, den `/post`-Befehl zu benutzen!",
# nur für ausführenden User
ephemeral=True,
)
COMMANDS = [
post,
]

View file

@ -0,0 +1,65 @@
import functools
import logging
import discord
from .config import CONFIG, FileCommand, InfoCommand
_logger = logging.getLogger(__name__)
def reply_private(interaction: discord.Interaction, name: str) -> bool:
_logger.debug(f"User {interaction.user.name}({interaction.user.id}) used /{name}")
return interaction.channel_id not in CONFIG.ev_info.channels
@functools.singledispatch
def make_command(command) -> discord.app_commands.Command:
raise NotImplementedError
@make_command.register
def _(command: FileCommand) -> discord.app_commands.Command:
@discord.app_commands.command(
name=CONFIG.command_prefix + command.name,
description=command.description,
)
async def cmd(interaction: discord.Interaction) -> None:
if (file := await command.as_discord_file) is not None:
await interaction.response.send_message(
content=command.content,
suppress_embeds=True,
ephemeral=reply_private(interaction, command.name),
file=file,
)
else:
await interaction.response.send_message(
content=CONFIG.command_failed,
ephemeral=True,
)
return cmd
@make_command.register
def _(command: InfoCommand) -> discord.app_commands.Command:
@discord.app_commands.command(
name=CONFIG.command_prefix + command.name,
description=command.description,
)
async def cmd(interaction: discord.Interaction) -> None:
await interaction.response.send_message(
content=command.content,
suppress_embeds=True,
ephemeral=reply_private(interaction, command.name),
)
return cmd
COMMANDS = [
make_command(attr)
for name in CONFIG.ev_info.model_dump().keys()
if isinstance(attr := getattr(CONFIG.ev_info, name), InfoCommand)
]

13
lenaverse_bot/main.py Normal file
View file

@ -0,0 +1,13 @@
from .core.bot import LenaverseBot
from .core.config import CONFIG
def main() -> None:
LenaverseBot().run(
token=CONFIG.discord_token,
log_handler=None,
)
if __name__ == "__main__":
main()

1013
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

23
pyproject.toml Normal file
View file

@ -0,0 +1,23 @@
[tool.poetry]
authors = ["Jörn-Michael Miehe <joern-michael.miehe@lenaisten.de>"]
description = ""
name = "lenaverse_bot"
readme = "README.md"
version = "0.1.0"
[tool.poetry.dependencies]
aiohttp = {extras = ["speedups"], version = "^3.9.0"}
discord-py = "^2.3.2"
pydantic = "^2.5.1"
python = "^3.11"
[tool.poetry.group.dev.dependencies]
black = "^23.11.0"
flake8 = "^6.1.0"
[tool.poetry.scripts]
lenaverse-bot = "lenaverse_bot.main:main"
[build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core"]