Clean up type hints everywhere, overhaul bot configuration
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 2m12s

This commit is contained in:
2025-09-23 20:39:11 +02:00
parent 591f4ea191
commit 75fb627361
6 changed files with 332 additions and 184 deletions

308
bot.py
View File

@ -1,26 +1,35 @@
# Example: https://github.com/Rapptz/discord.py/blob/master/examples/app_commands/basic.py
from ast import Call
import random, logging
from discord import DMChannel
import os
import random
import logging
from types import CoroutineType
from typing import Any, Callable, override
from discord import app_commands, ui
from discord.app_commands import Choice
from typing import Awaitable, Dict, List, Optional, Union, Callable, Any
from rich.traceback import install
from heidi_client import *
from discord.client import Client
from discord.components import SelectOption
from discord.enums import ButtonStyle
from discord.flags import Intents
from discord.interactions import Interaction
from discord.member import Member, VoiceState
from discord.message import Message
# Install rich traceback
install(show_locals=True)
from heidi_client import HeidiClient
from heidi_config import ConfigSection, FlagsConfigKey
from heidi_constants import DOCKER, HEIDI_SPAM_ID, SOUNDDIR
from heidi_helpers import enforce_channel, play_voice_line_for_member
# @todo yt-dlp music support
# TODO: yt-dlp music support
# Log to file
handler = logging.FileHandler(filename="discord.log", encoding="utf-8", mode="w")
# Intents specification is no longer optional
intents = discord.Intents.default()
intents: Intents = Intents.default()
intents.members = True # Allow to react to member join/leave etc
intents.message_content = True # Allow to read message content from arbitrary messages
intents.voice_states = True # Allow to process on_voice_state_update
@ -63,9 +72,7 @@ async def on_message(message: Message) -> None:
@client.event
async def on_voice_state_update(
member: Member, before: VoiceState, after: VoiceState
) -> None:
async def on_voice_state_update(member: Member, before: VoiceState, after: VoiceState) -> None:
"""
This event triggers when a member joins/changes/leaves a voice channel or mutes/unmutes.
"""
@ -77,7 +84,9 @@ async def on_voice_state_update(
# python iterates over the keys of a map
for predicate in client.on_voice_state_triggers:
if predicate(member, before, after):
action: Callable = client.on_voice_state_triggers[predicate]
action: Callable[[Member, VoiceState, VoiceState], CoroutineType[Any, Any, None]] = (
client.on_voice_state_triggers[predicate]
)
print(f"on_voice_state_update: calling {action.__name__}")
await action(member, before, after)
@ -85,114 +94,194 @@ async def on_voice_state_update(
# Config Commands --------------------------------------------------------------------------------
class EntranceSoundSoundSelect(discord.ui.Select):
def __init__(self, board: str, on_sound_select_callback):
self.board = board
self.on_sound_select_callback = on_sound_select_callback
options: List[discord.SelectOption] = [
discord.SelectOption(label=sound.split(".")[0], value=sound)
for sound in os.listdir(f"{SOUNDDIR}/{board}")
]
super().__init__(
placeholder="Select Sound", min_values=1, max_values=1, options=options
class FlagValueSelect(ui.Select[ui.View]):
def __init__(
self, flag: str, on_flag_select_callback: Callable[[Interaction, str, str], CoroutineType[Any, Any, None]]
) -> None:
self.flag: str = flag
self.on_flag_select_callback: Callable[[Interaction, str, str], CoroutineType[Any, Any, None]] = (
on_flag_select_callback
)
async def callback(self, interaction: Interaction):
await self.on_sound_select_callback(interaction, self.board, self.values[0])
options: list[SelectOption] = [SelectOption(label=val, value=val) for val in ["True", "False"]]
super().__init__(placeholder="Select Value", min_values=1, max_values=1, options=options)
@override
async def callback(self, interaction: Interaction) -> None:
await self.on_flag_select_callback(interaction, self.flag, self.values[0])
class EntranceSoundSoundView(discord.ui.View):
def __init__(self, board: str, on_sound_select_callback):
class FlagValueView(ui.View):
def __init__(
self, flag: str, on_flag_select_callback: Callable[[Interaction, str, str], CoroutineType[Any, Any, None]]
) -> None:
super().__init__(timeout=600)
self.add_item(EntranceSoundSoundSelect(board, on_sound_select_callback))
_ = self.add_item(FlagValueSelect(flag, on_flag_select_callback))
class EntranceSoundBoardSelect(discord.ui.Select):
def __init__(self, on_sound_select_callback):
self.on_sound_select_callback = on_sound_select_callback
options: List[discord.SelectOption] = [
discord.SelectOption(label=board, value=board)
for board in os.listdir(f"{SOUNDDIR}")
]
super().__init__(
placeholder="Select Board", min_values=1, max_values=1, options=options
class FlagsSelect(ui.Select[ui.View]):
def __init__(
self, on_flag_select_callback: Callable[[Interaction, str, str], CoroutineType[Any, Any, None]]
) -> None:
self.on_flag_select_callback: Callable[[Interaction, str, str], CoroutineType[Any, Any, None]] = (
on_flag_select_callback
)
async def callback(self, interaction: Interaction):
await interaction.response.send_message(
f"Welchen sound willst du?",
options: list[SelectOption] = [SelectOption(label=flag.value, value=flag.value) for flag in FlagsConfigKey]
super().__init__(placeholder="Select Flag", min_values=1, max_values=1, options=options)
@override
async def callback(self, interaction: Interaction) -> None:
_ = await interaction.response.send_message(
"Welchen Wert willst du setzen?",
view=FlagValueView(self.values[0], self.on_flag_select_callback),
ephemeral=True,
)
class FlagsView(ui.View):
def __init__(
self, on_flag_select_callback: Callable[[Interaction, str, str], CoroutineType[Any, Any, None]]
) -> None:
super().__init__(timeout=600)
_ = self.add_item(FlagsSelect(on_flag_select_callback))
class EntranceSoundBoardSelect(ui.Select[ui.View]):
def __init__(
self, on_sound_select_callback: Callable[[Interaction, str, str], CoroutineType[Any, Any, None]]
) -> None:
self.on_sound_select_callback: Callable[[Interaction, str, str], CoroutineType[Any, Any, None]] = (
on_sound_select_callback
)
options: list[SelectOption] = [SelectOption(label=board, value=board) for board in os.listdir(f"{SOUNDDIR}")]
super().__init__(placeholder="Select Board", min_values=1, max_values=1, options=options)
@override
async def callback(self, interaction: Interaction) -> None:
_ = await interaction.response.send_message(
"Welchen sound willst du?",
view=EntranceSoundSoundView(self.values[0], self.on_sound_select_callback),
ephemeral=True,
)
class EntranceSoundBoardView(discord.ui.View):
def __init__(self, on_sound_select_callback):
class EntranceSoundBoardView(ui.View):
def __init__(
self, on_sound_select_callback: Callable[[Interaction, str, str], CoroutineType[Any, Any, None]]
) -> None:
super().__init__(timeout=600)
self.add_item(EntranceSoundBoardSelect(on_sound_select_callback))
_ = self.add_item(EntranceSoundBoardSelect(on_sound_select_callback))
async def user_config_key_autocomplete(
interaction: Interaction, current: str
) -> List[Choice[str]]:
class EntranceSoundSoundSelect(ui.Select[ui.View]):
def __init__(
self, board: str, on_sound_select_callback: Callable[[Interaction, str, str], CoroutineType[Any, Any, None]]
) -> None:
self.board: str = board
self.on_sound_select_callback: Callable[[Interaction, str, str], CoroutineType[Any, Any, None]] = (
on_sound_select_callback
)
options: list[SelectOption] = [
SelectOption(label=sound.split(".")[0], value=sound) for sound in os.listdir(f"{SOUNDDIR}/{board}")
]
super().__init__(placeholder="Select Sound", min_values=1, max_values=1, options=options)
@override
async def callback(self, interaction: Interaction) -> None:
await self.on_sound_select_callback(interaction, self.board, self.values[0])
class EntranceSoundSoundView(ui.View):
def __init__(
self, board: str, on_sound_select_callback: Callable[[Interaction, str, str], CoroutineType[Any, Any, None]]
) -> None:
super().__init__(timeout=600)
_ = self.add_item(EntranceSoundSoundSelect(board, on_sound_select_callback))
async def user_config_key_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
"""
Suggest a value from the user config keys (each .conf section is a key).
"""
return [
Choice(name=key, value=key)
for key in client.user_config.sections()
if key.lower().startswith(current.lower())
Choice(name=key, value=key) for key in client.user_config.sections() if key.lower().startswith(current.lower())
]
@client.tree.command(
name="userconfig",
name="heidiconfig",
description="User-spezifische Heidi-Einstellungen (Heidi merkt sie sich in ihrem riesigen Gehirn).",
)
@app_commands.rename(config_key="option")
@app_commands.describe(config_key="Die Option, welche du ändern willst.")
@app_commands.autocomplete(config_key=user_config_key_autocomplete)
@enforce_channel(HEIDI_SPAM_ID)
async def user_config(interaction: Interaction, config_key: str) -> None:
async def user_config(interaction: Interaction[Client], config_key: str) -> None:
"""
Set a user config value for the calling user.
"""
# Only Members can set settings
if not isinstance(interaction.user, Member):
print("User not a member")
await interaction.response.send_message(
"Heidi sagt: Komm in die Gruppe!", ephemeral=True
)
_ = await interaction.response.send_message("Heidi sagt: Komm in die Gruppe!", ephemeral=True)
return
member: Member = interaction.user
async def on_sound_select_callback(interaction, board: str, sound: str):
async def on_flag_select_callback(interaction: Interaction, flag: str, value: str) -> None:
"""
This function is called, when an EntrySoundSoundSelect option is selected.
This function is called when an FlagValueSelect option is selected.
"""
client.user_config[ConfigSection.FLAGS.value][flag] = value
client.write_user_config()
_ = await interaction.response.send_message(
f"Ok, ich schreibe {flag}={value} in mein fettes Gehirn!",
ephemeral=True,
)
async def on_sound_select_callback(interaction: Interaction, board: str, sound: str) -> None:
"""
This function is called when an EntrySoundSoundSelect option is selected.
"""
client.user_config[config_key][member.name] = f"{board}/{sound}"
client.write_user_config()
await interaction.response.send_message(
_ = await interaction.response.send_message(
f"Ok, ich schreibe {member.name}={board}/{sound} in mein fettes Gehirn!",
ephemeral=True,
)
# Views for different user config options are defined here
views = {"ENTRANCE.SOUND": (EntranceSoundBoardView, on_sound_select_callback)}
views: dict[str, tuple[type[ui.View], Callable[..., CoroutineType[Any, Any, None]], str]] = {
ConfigSection.FLAGS.value: (
FlagsView,
on_flag_select_callback,
"Welches Setting möchtest du ändern?",
),
ConfigSection.ENTRANCE_SOUND.value: (
EntranceSoundBoardView,
on_sound_select_callback,
"Aus welchem Soundboard soll dein Sound sein?",
),
}
view, select_callback = views[config_key]
view, select_callback, description = views[config_key]
await interaction.response.send_message(
f"Aus welchem Soundboard soll dein sound sein?",
view=view(select_callback),
_ = await interaction.response.send_message(
description,
view=view(select_callback), # pyright: ignore[reportCallIssue]
ephemeral=True,
)
@ -219,7 +308,8 @@ async def heidi_exclaim(interaction: Interaction) -> None:
"Models müssen da halt durch!",
"Heul doch nicht!",
]
await interaction.response.send_message(random.choice(messages))
_ = await interaction.response.send_message(random.choice(messages))
@client.tree.command(name="miesmuschel", description="Was denkt Heidi?")
@ -247,14 +337,13 @@ async def magic_shell(interaction: Interaction, question: str) -> None:
"In deinen Träumen.",
"Tom sagt Nein",
]
question = question.strip()
question_mark = "" if question[-1] == "?" else "?"
await interaction.response.send_message(
f"{question}{question_mark}\nHeidi sagt: {random.choice(choices)}"
)
_ = await interaction.response.send_message(f"{question}{question_mark}\nHeidi sagt: {random.choice(choices)}")
# @todo Allow , separated varargs, need to parse manually as slash commands don't support varargs
# TODO: Allow separated varargs, need to parse manually as slash commands don't support varargs
@client.tree.command(name="wähle", description="Heidi trifft die Wahl!")
@app_commands.rename(option_a="entweder")
@app_commands.describe(option_a="Ist es vielleicht dies?")
@ -266,7 +355,7 @@ async def choose(interaction: Interaction, option_a: str, option_b: str) -> None
Select an answer from two options.
"""
options = [option_a.strip(), option_b.strip()]
await interaction.response.send_message(
_ = await interaction.response.send_message(
f"{options[0]} oder {options[1]}?\nHeidi sagt: {random.choice(options)}"
)
@ -274,40 +363,28 @@ async def choose(interaction: Interaction, option_a: str, option_b: str) -> None
# Sounds -----------------------------------------------------------------------------------------
async def board_autocomplete(
interaction: Interaction, current: str
) -> List[Choice[str]]:
async def board_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
"""
Suggest a sound board.
"""
boards: List[str] = os.listdir(SOUNDDIR)
boards: list[str] = os.listdir(SOUNDDIR)
return [
Choice(name=board, value=board)
for board in boards
if board.lower().startswith(current.lower())
]
return [Choice(name=board, value=board) for board in boards if board.lower().startswith(current.lower())]
async def sound_autocomplete(
interaction: Interaction, current: str
) -> List[Choice[str]]:
async def sound_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
"""
Suggest a sound from an already selected board.
"""
board: str = interaction.namespace.board
sounds: List[str] = os.listdir(f"{SOUNDDIR}/{board}/")
sounds: list[str] = os.listdir(f"{SOUNDDIR}/{board}/")
return [
Choice(name=sound.split(".")[0], value=sound)
for sound in sounds
if sound.lower().startswith(current.lower())
Choice(name=sound.split(".")[0], value=sound) for sound in sounds if sound.lower().startswith(current.lower())
]
@client.tree.command(
name="sag", description="Heidi drückt den Knopf auf dem Soundboard."
)
@client.tree.command(name="sag", description="Heidi drückt den Knopf auf dem Soundboard.")
@app_commands.describe(sound="Was soll Heidi sagen?")
@app_commands.autocomplete(board=board_autocomplete)
@app_commands.autocomplete(sound=sound_autocomplete)
@ -319,9 +396,7 @@ async def say_voiceline(interaction: Interaction, board: str, sound: str) -> Non
# Only Members can access voice channels
if not isinstance(interaction.user, Member):
print("User not a member")
await interaction.response.send_message(
"Heidi sagt: Komm in die Gruppe!", ephemeral=True
)
_ = await interaction.response.send_message("Heidi sagt: Komm in die Gruppe!", ephemeral=True)
return
member: Member = interaction.user
@ -329,47 +404,42 @@ async def say_voiceline(interaction: Interaction, board: str, sound: str) -> Non
await play_voice_line_for_member(interaction, member, board, sound)
class InstantButton(discord.ui.Button):
def __init__(self, label: str, board: str, sound: str):
super().__init__(style=discord.ButtonStyle.red, label=label)
class InstantButton(ui.Button[ui.View]):
def __init__(self, label: str, board: str, sound: str) -> None:
super().__init__(style=ButtonStyle.red, label=label)
self.board = board
self.sound = sound
self.board: str = board
self.sound: str = sound
async def callback(self, interaction: Interaction):
@override
async def callback(self, interaction: Interaction) -> None:
"""
Handle a press of the button.
"""
if not isinstance(interaction.user, Member):
await interaction.response.send_message(
_ = await interaction.response.send_message(
"Heidi mag keine discord.User, nur discord.Member!", ephemeral=True
)
return
await play_voice_line_for_member(
interaction, interaction.user, self.board, self.sound
)
await play_voice_line_for_member(interaction, interaction.user, self.board, self.sound)
class InstantButtonsView(discord.ui.View):
def __init__(self, board: str, timeout=None):
class InstantButtonsView(ui.View):
def __init__(self, board: str, timeout: float | None = None) -> None:
super().__init__(timeout=timeout)
sounds = os.listdir(f"{SOUNDDIR}/{board}")
for sound in sounds:
self.add_item(InstantButton(sound.split(".")[0], board, sound))
_ = self.add_item(InstantButton(sound.split(".")[0], board, sound))
@client.tree.command(
name="instantbuttons", description="Heidi malt Knöpfe für Sounds in den Chat."
)
@client.tree.command(name="instantbuttons", description="Heidi malt Knöpfe für Sounds in den Chat.")
@app_commands.describe(board="Welches Soundboard soll knöpfe bekommen?")
@app_commands.autocomplete(board=board_autocomplete)
@enforce_channel(HEIDI_SPAM_ID)
async def soundboard_buttons(interaction: Interaction, board: str) -> None:
await interaction.response.send_message(
f"Soundboard: {board.capitalize()}", view=InstantButtonsView(board)
)
_ = await interaction.response.send_message(f"Soundboard: {board.capitalize()}", view=InstantButtonsView(board))
# Contextmenu ------------------------------------------------------------------------------------
@ -384,13 +454,11 @@ async def insult(
Send an insult to a member via direct message.
"""
if not member.dm_channel:
await member.create_dm()
_ = await member.create_dm()
if not member.dm_channel:
print("Error creating DMChannel!")
await interaction.response.send_message(
"Heidi sagt: Gib mal DM Nummer süße*r!", ephemeral=True
)
_ = await interaction.response.send_message("Heidi sagt: Gib mal DM Nummer süße*r!", ephemeral=True)
return
insults = [
@ -409,8 +477,8 @@ async def insult(
"Richtiger Gesichtsgünther ey!",
]
await member.dm_channel.send(random.choice(insults))
await interaction.response.send_message(
_ = await member.dm_channel.send(random.choice(insults))
_ = await interaction.response.send_message(
"Anzeige ist raus!", ephemeral=True
) # with ephemeral = True only the caller can see the answer