Compare commits
30 commits
2d3b357307
...
9e782da823
Author | SHA1 | Date | |
---|---|---|---|
9e782da823 | |||
3c3f97cddc | |||
32b098dc76 | |||
320126fc23 | |||
29af971c39 | |||
7c4dc07273 | |||
4a7851c50d | |||
8c73e401ed | |||
534c31bbe4 | |||
d3b7a445c5 | |||
f64cfbf1c0 | |||
64cb7bd5a9 | |||
2978d7f98f | |||
6cb7feddd2 | |||
13dcddb135 | |||
26a48cce46 | |||
59ce255fda | |||
f7c24ebca5 | |||
ec01447440 | |||
3bc24d45af | |||
a7a1a3242e | |||
6f0c6d8f66 | |||
b11c39da18 | |||
8d68a5bec8 | |||
1353c76f3c | |||
8a328270cd | |||
20da5bd078 | |||
ad7350b4c1 | |||
5a630aff26 | |||
876cd4ab53 |
15 changed files with 1410 additions and 6 deletions
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
2
.flake8
2
.flake8
|
@ -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
2
.gitignore
vendored
|
@ -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
19
.vscode/launch.json
vendored
Normal 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# env setup
|
||||||
|
WORKDIR /usr/local/src/lenaverse_bot
|
||||||
|
|
||||||
|
# install lenaverse_bot
|
||||||
|
COPY . ./
|
||||||
|
RUN set -ex; \
|
||||||
|
# remove example app
|
||||||
|
rm -rf /app; \
|
||||||
|
\
|
||||||
|
python -m pip --no-cache-dir install ./
|
||||||
|
|
||||||
|
# run as unprivileged user
|
||||||
|
USER nobody
|
||||||
|
|
||||||
|
CMD [ "lenaverse-bot" ]
|
2
LICENSE
2
LICENSE
|
@ -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:
|
||||||
|
|
||||||
|
|
|
@ -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`.
|
||||||
|
|
9
lenaverse_bot/__init__.py
Normal file
9
lenaverse_bot/__init__.py
Normal 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.INFO))
|
25
lenaverse_bot/core/bot.py
Normal file
25
lenaverse_bot/core/bot.py
Normal 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!")
|
91
lenaverse_bot/core/config.py
Normal file
91
lenaverse_bot/core/config.py
Normal 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
122
lenaverse_bot/core/post.py
Normal 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,
|
||||||
|
]
|
65
lenaverse_bot/core/verein.py
Normal file
65
lenaverse_bot/core/verein.py
Normal 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
13
lenaverse_bot/main.py
Normal 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
1013
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
23
pyproject.toml
Normal file
23
pyproject.toml
Normal 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"]
|
Loading…
Reference in a new issue