5 Commits

Author SHA1 Message Date
d04244221b Replace ENTRANCE.SOUND menu with dropdowns
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 14s
2023-12-09 23:01:24 +01:00
c2847de7dd Add instantbuttons command + make responses ephemeral
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 14s
/instantbuttons displays a soundboard via a button ui
2023-12-09 19:51:28 +01:00
08230eb3de Enforce heidi_spam channel for commands
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 14s
2023-12-09 18:44:16 +01:00
f2ddb4ab66 Only play entrance sound when other is present + reformat
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 14s
2023-12-09 18:04:24 +01:00
876232f674 Ignore user config file 2023-12-09 18:03:42 +01:00
5 changed files with 207 additions and 80 deletions

1
.gitignore vendored
View File

@ -12,3 +12,4 @@ Pipfile.lock
/disabled_voicelines/ /disabled_voicelines/
*.svg *.svg
.vscode .vscode
Heidi_User.conf

192
bot.py
View File

@ -1,8 +1,10 @@
# Example: https://github.com/Rapptz/discord.py/blob/master/examples/app_commands/basic.py # Example: https://github.com/Rapptz/discord.py/blob/master/examples/app_commands/basic.py
from ast import Call
import random, logging import random, logging
from discord import DMChannel
from discord.app_commands import Choice from discord.app_commands import Choice
from typing import Dict, List, Optional, Union, Callable, Any from typing import Awaitable, Dict, List, Optional, Union, Callable, Any
from rich.traceback import install from rich.traceback import install
from heidi_client import * from heidi_client import *
@ -11,9 +13,7 @@ from heidi_client import *
install(show_locals=True) install(show_locals=True)
# @todo Only post in heidi-spam channel
# @todo yt-dlp music support # @todo yt-dlp music support
# @todo Somehow upload voicelines more easily (from discord voice message?)
# Log to file # Log to file
@ -85,6 +85,59 @@ async def on_voice_state_update(
# Config Commands -------------------------------------------------------------------------------- # 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
)
async def callback(self, interaction: Interaction):
await self.on_sound_select_callback(interaction, self.board, self.values[0])
class EntranceSoundSoundView(discord.ui.View):
def __init__(self, board: str, on_sound_select_callback):
super().__init__(timeout=600)
self.add_item(EntranceSoundSoundSelect(board, on_sound_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
)
async def callback(self, interaction: Interaction):
await interaction.response.send_message(
f"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):
super().__init__(timeout=600)
self.add_item(EntranceSoundBoardSelect(on_sound_select_callback))
async def user_config_key_autocomplete( async def user_config_key_autocomplete(
interaction: Interaction, current: str interaction: Interaction, current: str
) -> List[Choice[str]]: ) -> List[Choice[str]]:
@ -98,46 +151,6 @@ async def user_config_key_autocomplete(
] ]
async def user_config_value_autocomplete(
interaction: Interaction, current: str
) -> List[Choice[str]]:
"""
Calls an autocomplete function depending on the entered config_key.
"""
autocompleters = {"ENTRANCE.SOUND": user_entrance_sound_autocomplete}
autocompleter = autocompleters[interaction.namespace.option]
print(f"config_value_autocomplete: calling {autocompleter.__name__}")
return autocompleter(interaction, current)
def user_entrance_sound_autocomplete(
interaction: Interaction, current: str
) -> List[Choice[str]]:
"""
Generates autocomplete options for the ENTRANCE.SOUND config key.
"""
boards: List[str] = os.listdir(SOUNDDIR)
all_sounds: Dict[str, List[str]] = {
board: os.listdir(f"{SOUNDDIR}/{board}/")
for board in boards
} # These are all sounds, organized per board, without file extension
# @todo Initially only suggest boards, because there are too many sounds to show them all
completions: List[Choice[str]] = []
for (
board,
board_sounds,
) in all_sounds.items(): # Iterate over all sounds, organized per board
for sound in board_sounds: # Iterate over board specific sounds
soundpath = f"{board}/{sound}"
if soundpath.lower().startswith(current.lower()):
completions += [Choice(name=soundpath.split(".")[0], value=soundpath)]
return completions
@client.tree.command( @client.tree.command(
name="userconfig", name="userconfig",
description="User-spezifische Heidi-Einstellungen (Heidi merkt sie sich in ihrem riesigen Gehirn).", description="User-spezifische Heidi-Einstellungen (Heidi merkt sie sich in ihrem riesigen Gehirn).",
@ -145,30 +158,42 @@ def user_entrance_sound_autocomplete(
@app_commands.rename(config_key="option") @app_commands.rename(config_key="option")
@app_commands.describe(config_key="Die Option, welche du ändern willst.") @app_commands.describe(config_key="Die Option, welche du ändern willst.")
@app_commands.autocomplete(config_key=user_config_key_autocomplete) @app_commands.autocomplete(config_key=user_config_key_autocomplete)
@app_commands.rename(config_value="wert") @enforce_channel(HEIDI_SPAM_ID)
@app_commands.describe( async def user_config(interaction: Interaction, config_key: str) -> None:
config_value="Der Wert, auf welche die Option gesetzt werden soll."
)
@app_commands.autocomplete(config_value=user_config_value_autocomplete)
async def user_config(
interaction: Interaction, config_key: str, config_value: str
) -> None:
""" """
Set a user config value for the calling user. Set a user config value for the calling user.
""" """
# Only Members can set settings # Only Members can set settings
if not isinstance(interaction.user, Member): if not isinstance(interaction.user, Member):
print("User not a member") print("User not a member")
await interaction.response.send_message("Heidi sagt: Komm in die Gruppe!") await interaction.response.send_message(
"Heidi sagt: Komm in die Gruppe!", ephemeral=True
)
return return
member: Member = interaction.user member: Member = interaction.user
client.user_config[config_key][member.name] = config_value async def on_sound_select_callback(interaction, board: str, sound: str):
"""
This function is called, when an EntrySoundSoundSelect option is selected.
"""
client.user_config[config_key][member.name] = f"{board}/{sound}"
client.write_user_config() client.write_user_config()
await interaction.response.send_message( await interaction.response.send_message(
f"Ok, ich schreibe {member.name}={config_value} in mein fettes Gehirn!" 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)}
view, select_callback = views[config_key]
await interaction.response.send_message(
f"Aus welchem Soundboard soll dein sound sein?",
view=view(select_callback),
ephemeral=True,
) )
@ -176,6 +201,7 @@ async def user_config(
@client.tree.command(name="heidi", description="Heidi!") @client.tree.command(name="heidi", description="Heidi!")
@enforce_channel(HEIDI_SPAM_ID)
async def heidi_exclaim(interaction: Interaction) -> None: async def heidi_exclaim(interaction: Interaction) -> None:
""" """
Print a random Heidi quote. Print a random Heidi quote.
@ -191,7 +217,7 @@ async def heidi_exclaim(interaction: Interaction) -> None:
"Jetzt sei doch mal sexy!", "Jetzt sei doch mal sexy!",
"Stell dich nicht so an!", "Stell dich nicht so an!",
"Models müssen da halt durch!", "Models müssen da halt durch!",
"Heul doch nicht!" "Heul doch nicht!",
] ]
await interaction.response.send_message(random.choice(messages)) await interaction.response.send_message(random.choice(messages))
@ -199,6 +225,7 @@ async def heidi_exclaim(interaction: Interaction) -> None:
@client.tree.command(name="miesmuschel", description="Was denkt Heidi?") @client.tree.command(name="miesmuschel", description="Was denkt Heidi?")
@app_commands.rename(question="frage") @app_commands.rename(question="frage")
@app_commands.describe(question="Heidi wird es beantworten!") @app_commands.describe(question="Heidi wird es beantworten!")
@enforce_channel(HEIDI_SPAM_ID)
async def magic_shell(interaction: Interaction, question: str) -> None: async def magic_shell(interaction: Interaction, question: str) -> None:
""" """
Answer a yes/no question. Answer a yes/no question.
@ -233,6 +260,7 @@ async def magic_shell(interaction: Interaction, question: str) -> None:
@app_commands.describe(option_a="Ist es vielleicht dies?") @app_commands.describe(option_a="Ist es vielleicht dies?")
@app_commands.rename(option_b="oder") @app_commands.rename(option_b="oder")
@app_commands.describe(option_b="Oder doch eher das?") @app_commands.describe(option_b="Oder doch eher das?")
@enforce_channel(HEIDI_SPAM_ID)
async def choose(interaction: Interaction, option_a: str, option_b: str) -> None: async def choose(interaction: Interaction, option_a: str, option_b: str) -> None:
""" """
Select an answer from two options. Select an answer from two options.
@ -283,6 +311,7 @@ async def sound_autocomplete(
@app_commands.describe(sound="Was soll Heidi sagen?") @app_commands.describe(sound="Was soll Heidi sagen?")
@app_commands.autocomplete(board=board_autocomplete) @app_commands.autocomplete(board=board_autocomplete)
@app_commands.autocomplete(sound=sound_autocomplete) @app_commands.autocomplete(sound=sound_autocomplete)
@enforce_channel(HEIDI_SPAM_ID)
async def say_voiceline(interaction: Interaction, board: str, sound: str) -> None: async def say_voiceline(interaction: Interaction, board: str, sound: str) -> None:
""" """
Play a voiceline in the calling member's current voice channel. Play a voiceline in the calling member's current voice channel.
@ -290,7 +319,9 @@ async def say_voiceline(interaction: Interaction, board: str, sound: str) -> Non
# Only Members can access voice channels # Only Members can access voice channels
if not isinstance(interaction.user, Member): if not isinstance(interaction.user, Member):
print("User not a member") print("User not a member")
await interaction.response.send_message("Heidi sagt: Komm in die Gruppe!") await interaction.response.send_message(
"Heidi sagt: Komm in die Gruppe!", ephemeral=True
)
return return
member: Member = interaction.user member: Member = interaction.user
@ -298,6 +329,49 @@ async def say_voiceline(interaction: Interaction, board: str, sound: str) -> Non
await play_voice_line_for_member(interaction, member, board, sound) 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)
self.board = board
self.sound = sound
async def callback(self, interaction: Interaction):
"""
Handle a press of the button.
"""
if not isinstance(interaction.user, Member):
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
)
class InstantButtonsView(discord.ui.View):
def __init__(self, board: str, timeout=None):
super().__init__(timeout=timeout)
sounds = os.listdir(f"{SOUNDDIR}/{board}")
for sound in sounds:
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."
)
@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)
)
# Contextmenu ------------------------------------------------------------------------------------ # Contextmenu ------------------------------------------------------------------------------------
@ -314,7 +388,9 @@ async def insult(
if not member.dm_channel: if not member.dm_channel:
print("Error creating DMChannel!") print("Error creating DMChannel!")
await interaction.response.send_message("Heidi sagt: Gib mal DM Nummer süße*r!") await interaction.response.send_message(
"Heidi sagt: Gib mal DM Nummer süße*r!", ephemeral=True
)
return return
insults = [ insults = [
@ -335,7 +411,7 @@ async def insult(
await member.dm_channel.send(random.choice(insults)) await member.dm_channel.send(random.choice(insults))
await interaction.response.send_message( await interaction.response.send_message(
"Anzeige ist raus!" "Anzeige ist raus!", ephemeral=True
) # with ephemeral = True only the caller can see the answer ) # with ephemeral = True only the caller can see the answer

View File

@ -115,9 +115,20 @@ class HeidiClient(discord.Client):
after: VoiceState, after: VoiceState,
) -> None: ) -> None:
""" """
Play a sound when a member joins a voice channel. Play a sound when a member joins a voice channel (and another member is present).
This function is set in on_voice_state_triggers and triggered by the on_voice_state_update event. This function is set in on_voice_state_triggers and triggered by the on_voice_state_update event.
""" """
# Don't play anything when no other users are present
if (
member is not None
and member.voice is not None
and member.voice.channel is not None
and len(member.voice.channel.members) <= 1
):
print("Not playing entrance sound, as no other members are present")
return
soundpath: Union[str, None] = self.user_config["ENTRANCE.SOUND"].get( soundpath: Union[str, None] = self.user_config["ENTRANCE.SOUND"].get(
member.name, None member.name, None
) )

View File

@ -24,3 +24,6 @@ SOUNDDIR: str = "/sounds" if DOCKER else "./heidi-sounds"
# IDs of the servers Heidi is used on # IDs of the servers Heidi is used on
LINUS_GUILD = discord.Object(id=431154792308408340) LINUS_GUILD = discord.Object(id=431154792308408340)
TEST_GUILD = discord.Object(id=821511861178204161) TEST_GUILD = discord.Object(id=821511861178204161)
# Channel IDs
HEIDI_SPAM_ID = 822223476101742682

View File

@ -1,4 +1,5 @@
import asyncio import asyncio
import functools
from typing import Union from typing import Union
import discord import discord
@ -8,6 +9,40 @@ from heidi_constants import *
print("Debug: Importing heidi_helpers.py") print("Debug: Importing heidi_helpers.py")
# Checks -----------------------------------------------------------------------------------------
# 1. @enforce_channel(ID) is added to a function, which evaluates to decorate with the channel_id in its closure
# 2. The function is passed to decorate(function),
def enforce_channel(channel_id):
"""
Only run a function if called from the correct channel.
"""
def decorate(function):
@functools.wraps(function)
async def wrapped(*args, **kwargs):
"""
Sends an interaction response if the interaction is not triggered from the heidi_spam channel.
"""
interaction: Interaction = args[0]
# Do not call the decorated function if the channel_id doesn't match
if not interaction.channel_id == channel_id:
await interaction.response.send_message("Heidi sagt: Geh in heidi_spam du dulli", ephemeral=True)
return
await function(*args, **kwargs)
return wrapped
return decorate
# Sounds -----------------------------------------------------------------------------------------
# @todo Normalize volume when playing # @todo Normalize volume when playing
async def play_voice_line( async def play_voice_line(
interaction: Union[Interaction, None], interaction: Union[Interaction, None],
@ -24,12 +59,13 @@ async def play_voice_line(
print(f"Error: Invalid soundfile {SOUNDDIR}/{board}/{sound}!") print(f"Error: Invalid soundfile {SOUNDDIR}/{board}/{sound}!")
if interaction is not None: if interaction is not None:
await interaction.response.send_message( await interaction.response.send_message(
f'Heidi sagt: "{board}/{sound}" kanninich finden bruder' f'Heidi sagt: "{board}/{sound}" kanninich finden bruder',
ephemeral=True
) )
return return
if interaction is not None: if interaction is not None:
await interaction.response.send_message(f'Heidi sagt: "{board}/{sound}"') await interaction.response.send_message(f'Heidi sagt: "{board}/{sound}"', ephemeral=True)
audio_source = discord.FFmpegPCMAudio( audio_source = discord.FFmpegPCMAudio(
f"{SOUNDDIR}/{board}/{sound}" f"{SOUNDDIR}/{board}/{sound}"
@ -61,7 +97,7 @@ async def play_voice_line_for_member(
): ):
print("User not in (valid) voice channel!") print("User not in (valid) voice channel!")
if interaction is not None: if interaction is not None:
await interaction.response.send_message("Heidi sagt: Komm in den Channel!") await interaction.response.send_message("Heidi sagt: Komm in den Channel!", ephemeral=True)
return return
voice_channel: VoiceChannel = member.voice.channel voice_channel: VoiceChannel = member.voice.channel