Compare commits

...

30 commits

Author SHA1 Message Date
9e782da823 Merge branch 'release/0.1.0' 2023-11-25 01:03:06 +01:00
3c3f97cddc Dockerfile + LOG_LEVEL 2023-11-25 01:02:55 +01:00
32b098dc76 LICENSE, README 2023-11-25 00:04:47 +01:00
320126fc23 code deduplication 2023-11-22 23:44:55 +01:00
29af971c39 /beitreten command update 2023-11-22 22:37:30 +01:00
7c4dc07273 "join" -> "beitreten" 2023-11-21 17:37:48 +01:00
4a7851c50d /vorstand command 2023-11-21 16:02:43 +01:00
8c73e401ed Always suppress_embeds 2023-11-21 14:57:51 +01:00
534c31bbe4 InfoCommand.name field 2023-11-20 14:40:16 +01:00
d3b7a445c5 FileCommand: get on demand
- remove files directory
- remove git lfs
- use `aiohttp` to fetch files
2023-11-20 14:29:39 +01:00
f64cfbf1c0 InfoCommands: info, fest, aktion 2023-11-20 13:55:20 +01:00
64cb7bd5a9 config rework, command_prefix, remove clutter
- _helpers.ev_command respects CONFIG.command_prefix
- restrict public replies to channel ids
2023-11-20 13:40:25 +01:00
2978d7f98f /post timeout handling 2023-11-20 01:27:33 +01:00
6cb7feddd2 command lists 2023-11-20 00:43:50 +01:00
13dcddb135 Add "Aufnahmeantrag" to git lfs 2023-11-20 00:28:12 +01:00
26a48cce46 add /join and /linktree commands 2023-11-20 00:26:23 +01:00
59ce255fda pyproject.toml reformat 2023-11-19 23:38:49 +01:00
f7c24ebca5 minor fixes 2023-11-19 23:37:51 +01:00
ec01447440 minimum scope for bot 2023-11-19 19:36:29 +01:00
3bc24d45af doc 2023-11-19 19:12:06 +01:00
a7a1a3242e logging improvements 2023-11-19 19:02:05 +01:00
6f0c6d8f66 /post command add author and logging 2023-11-19 17:58:38 +01:00
b11c39da18 /post command target and authorization 2023-11-19 17:32:12 +01:00
8d68a5bec8 Add CONFIG.discord_token 2023-11-19 16:41:19 +01:00
1353c76f3c /post command some polish 2023-11-19 16:27:10 +01:00
8a328270cd "lsstuff" app command 2023-11-18 22:27:30 +01:00
20da5bd078 Test dummy "post" app_command 2023-11-18 21:52:07 +01:00
ad7350b4c1 dummy commands 2023-11-17 11:49:50 +01:00
5a630aff26 empty discord bot 2023-11-17 10:53:10 +01:00
876cd4ab53 python3.11: main dependencies and "Hello World" 2023-11-17 10:27:38 +01:00
15 changed files with 1410 additions and 6 deletions

View file

@ -1,7 +1,7 @@
// 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
{
"name": "Python 3",
"name": "LEVbot",
"build": {
"dockerfile": "Dockerfile",
"context": "..",
@ -9,7 +9,7 @@
// Update 'VARIANT' to pick a Python version.
// Append -bookworm, -bullseye or -buster to pin to an OS version.
// Use -bookworm or -bullseye variants on arm64/Apple Silicon.
"VARIANT": "3.12",
"VARIANT": "3.11",
// Options
"NODE_VERSION": "none"
}

View file

@ -1,4 +1,4 @@
[flake8]
max-line-length = 80
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
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.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",
},
}
]
}

17
Dockerfile Normal file
View 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" ]

View file

@ -1,6 +1,6 @@
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:

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.INFO))

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"]