17 Commits

Author SHA1 Message Date
b6fbed1c49 Update workflow registry path
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 8s
2023-11-26 00:41:53 +01:00
e430b99e35 Update workflow registry path
Some checks failed
Build Heidi Docker image / build-docker (push) Failing after 9s
2023-11-26 00:41:17 +01:00
626611f5a8 Update dockerfile for unbuffered print
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 7s
2023-11-26 00:39:46 +01:00
56b625b5b8 Set DOCKER using env
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 8s
2023-11-26 00:36:25 +01:00
e3e42dc235 Add kaptn sounds 2023-11-26 00:33:20 +01:00
1c8a6c3945 Add /userconfig command response
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 9s
2023-11-26 00:16:07 +01:00
e94fbdd716 Implement initial entrance sounds
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 8s
2023-11-26 00:08:26 +01:00
361de0f5eb Delete Heidi.conf 2023-11-26 00:08:18 +01:00
d8c1ca48a9 Add mp3 to LFS 2023-11-25 21:40:50 +01:00
0fa5807442 Add default Heidi.conf
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 8s
2023-11-25 21:20:01 +01:00
222b5b99f3 Add ENTRANCE.SOUND config section + config file writing 2023-11-25 21:19:53 +01:00
a34e4fc01b Only compare lower case strings for autocomplete + userconfig command
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 8s
2023-11-25 20:21:42 +01:00
7250d7bb7d Autoreact to kardashian/jenner
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 7s
2023-11-25 20:12:36 +01:00
48e47f081a Cleanup + start persistant config features
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 9s
2023-11-25 20:08:32 +01:00
a0e184f2fc Take current input into account for autocomplete + reformat
All checks were successful
Build Heidi Docker image / build-docker (push) Successful in 17s
2023-11-25 19:37:02 +01:00
273b3ff406 Update flake 2023-11-25 19:36:33 +01:00
29d4dff673 Update .gitignore 2023-11-25 19:36:28 +01:00
12 changed files with 450 additions and 141 deletions

1
.gitattributes vendored
View File

@ -1 +1,2 @@
*.mkv filter=lfs diff=lfs merge=lfs -text *.mkv filter=lfs diff=lfs merge=lfs -text
*.mp3 filter=lfs diff=lfs merge=lfs -text

View File

@ -4,6 +4,7 @@ on:
push: push:
branches: [master] branches: [master]
paths: paths:
- ".gitea/workflows/*"
- "Dockerfile" - "Dockerfile"
- "*.py" - "*.py"
- "requirements.txt" - "requirements.txt"
@ -17,10 +18,10 @@ jobs:
- name: Login to container registry - name: Login to container registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: gitea.local.chriphost.de registry: gitea.vps.chriphost.de
username: ${{ secrets.CONTAINER_REGISTRY_USER }} username: ${{ secrets.CONTAINER_REGISTRY_USER }}
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }} password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Build Docker image - name: Build Docker image
run: docker build . --file Dockerfile --tag gitea.local.chriphost.de/christoph/discord-heidi:latest run: docker build . --file Dockerfile --tag gitea.vps.chriphost.de/christoph/discord-heidi:latest
- name: Push Docker image - name: Push Docker image
run: docker push gitea.local.chriphost.de/christoph/discord-heidi:latest run: docker push gitea.vps.chriphost.de/christoph/discord-heidi:latest

1
.gitignore vendored
View File

@ -11,3 +11,4 @@ Pipfile.lock
/discord.log /discord.log
/disabled_voicelines/ /disabled_voicelines/
*.svg *.svg
.vscode

View File

@ -7,4 +7,4 @@ WORKDIR /app
COPY requirements.txt requirements.txt COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt RUN pip3 install -r requirements.txt
COPY . . COPY . .
CMD ["python3", "bot.py"] CMD ["python3", "-u", "bot.py"]

467
bot.py
View File

@ -1,11 +1,11 @@
# 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
import os, re, random, logging, asyncio, discord import os, re, random, logging, asyncio, discord, configparser
from discord import app_commands from discord import app_commands
from discord.app_commands import Choice from discord.app_commands import Choice
from functools import reduce from functools import reduce
from dotenv import load_dotenv from dotenv import load_dotenv
from typing import Optional, Union from typing import Dict, List, Optional, Union
# TODO: Reenable + extend textgen # TODO: Reenable + extend textgen
# from textgen import textgen # from textgen import textgen
@ -17,28 +17,31 @@ from typing import Optional, Union
# We're fancy today # We're fancy today
from rich.traceback import install from rich.traceback import install
install(show_locals=True) install(show_locals=True)
load_dotenv()
# ================================================================================================ # # ================================================================================================ #
# ================================================================================================ # # ================================================================================================ #
# NOTE: Always set this correctly: # # NOTE: Always set this correctly:
DOCKER = True # DOCKER = os.getenv("DOCKER") == "True"
# ================================================================================================ # # ================================================================================================ #
# ================================================================================================ # # ================================================================================================ #
# DONE: Migrate back to discord.py
# DONE: Rewrite bot with slash commands (and making actual use of discord.py)
# TODO: Insult statistics (you have insulted 20 times)
# TODO: Only post in heidi-spam channel # 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?), also need to be distributed to folders so no more than 25 lines per folder # TODO: Somehow upload voicelines more easily (from discord voice message?)
# TODO: Reenable text/quote generation, allow uploading of training text files, allow switching "personalities" (/elon generates elon quote?)
# TODO: Zalgo generator
# 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)
CONFIGPATH = "/config" if DOCKER else "."
USERCONFIGNAME = "Heidi_User.conf"
class HeidiClient(discord.Client): class HeidiClient(discord.Client):
def __init__(self, *, intents: discord.Intents): def __init__(self, *, intents: discord.Intents):
super().__init__(status="Nur eine kann GNTM werden!", intents=intents) super().__init__(status="Nur eine kann GNTM werden!", intents=intents)
@ -46,40 +49,60 @@ class HeidiClient(discord.Client):
# Separate object that keeps all application command state # Separate object that keeps all application command state
self.tree = app_commands.CommandTree(self) self.tree = app_commands.CommandTree(self)
# Handle persistent user configuration
self.user_config = configparser.ConfigParser()
if not os.path.exists(f"{CONFIGPATH}/{USERCONFIGNAME}"):
os.mknod(f"{CONFIGPATH}/{USERCONFIGNAME}")
self.user_config.read(f"{CONFIGPATH}/{USERCONFIGNAME}")
self.update_to_default_user_config()
self.print_user_config()
# self.models = Models() # scraped model list # self.models = Models() # scraped model list
# automatic actions on all messages # automatic actions on all messages
# auto_triggers is a map with tuples of two functions: (predicate, action) # on_message_triggers is a map with tuples of two functions: (predicate, action)
# the predicate receives the message as argument
# if the predicate is true the action is performed # if the predicate is true the action is performed
self.auto_triggers = { self.on_message_triggers = {
# lambda m: m.author.nick.lower() in self.models.get_in_names(): self.autoreact_to_girls, # lambda m: m.author.nick.lower() in self.models.get_in_names(): self.autoreact_to_girls,
lambda m: "jeremy" in m.author.nick.lower(): self._autoreact_to_jeremy lambda m: "jeremy" in m.author.nick.lower(): self._autoreact_to_jeremy,
lambda m: "kardashian" in m.author.nick.lower()
or "jenner" in m.author.nick.lower(): self._autoreact_to_kardashian,
}
# automatic actions on voice state changes
# on_voice_state_triggers is a map with tuples of two functions: (predicate, action)
# the predicate receives the member, before- and after-state as arguments
# if the predicate is true, the action is performed
self.on_voice_state_triggers = {
lambda m, b, a: b.channel != a.channel
and a.channel != None
and isinstance(a.channel, discord.VoiceChannel): self._play_entrance_sound,
} }
# Textgen # Textgen
self.textgen_models: dict[str, textgen] = { # self.textgen_models: dict[str, textgen] = {
# The name must correspond to the name of the training text file # # The name must correspond to the name of the training text file
# "kommunistisches_manifest": LSTMTextGenerator(10), # "kommunistisches_manifest": LSTMTextGenerator(10),
# "musk": LSTMTextGenerator(10), # "musk": LSTMTextGenerator(10),
# "bibel": LSTMTextGenerator(10) # "bibel": LSTMTextGenerator(10)
# "bibel": MarkovTextGenerator(3), # Prefix length of 3
# "kommunistisches_manifest": MarkovTextGenerator(3),
# "musk": MarkovTextGenerator(3)
# }
# "bibel": MarkovTextGenerator(3), # Prefix length of 3 # for name, model in self.textgen_models.items():
# "kommunistisches_manifest": MarkovTextGenerator(3), # model.init(name) # Loads the textfile
# "musk": MarkovTextGenerator(3)
}
for name, model in self.textgen_models.items(): # if os.path.exists(f"weights/{name}_lstm_model.pt"):
model.init(name) # Loads the textfile # model.load()
# elif not DOCKER:
# model.train()
# else:
# print("Error: Can't load model", name)
if os.path.exists(f"weights/{name}_lstm_model.pt"): # print("Generating test sentence for", name)
model.load() # self.textgen_models[name].generate_sentence()
elif not DOCKER:
model.train()
else:
print("Error: Can't load model", name)
print("Generating test sentence for", name)
self.textgen_models[name].generate_sentence()
# Synchronize commands to guilds # Synchronize commands to guilds
async def setup_hook(self): async def setup_hook(self):
@ -89,6 +112,39 @@ class HeidiClient(discord.Client):
self.tree.copy_global_to(guild=TEST_GUILD) self.tree.copy_global_to(guild=TEST_GUILD)
await self.tree.sync(guild=TEST_GUILD) await self.tree.sync(guild=TEST_GUILD)
def update_to_default_user_config(self):
"""
Adds config keys to the config, if they don't exist yet.
"""
user_config_sections = ["ENTRANCE.SOUND"]
for section in user_config_sections:
if section not in self.user_config:
print(f"Adding section {section} to {CONFIGPATH}/{USERCONFIGNAME}")
self.user_config[section] = dict()
self.write_user_config()
def print_user_config(self):
print("Read persistent configuration:\n")
for section in self.user_config.sections():
print(f"[{section}]")
for key in self.user_config[section]:
print(f"{key}={self.user_config[section][key]}")
print("")
def write_user_config(self):
if not os.path.exists(f"{CONFIGPATH}/{USERCONFIGNAME}"):
print(f"Error: {CONFIGPATH}/{USERCONFIGNAME} doesn't exist!")
return
print(f"Writing {CONFIGPATH}/{USERCONFIGNAME}")
with open(f"{CONFIGPATH}/{USERCONFIGNAME}", "w") as file:
self.user_config.write(file)
# Commands ----------------------------------------------------------------------------------- # Commands -----------------------------------------------------------------------------------
# async def list_models_in(self, message): # async def list_models_in(self, message):
@ -113,7 +169,6 @@ class HeidiClient(discord.Client):
# picture.set_footer(text=name) # picture.set_footer(text=name)
# await message.channel.send(embed=picture) # await message.channel.send(embed=picture)
# Automatic Actions -------------------------------------------------------------------------- # Automatic Actions --------------------------------------------------------------------------
# @staticmethod # @staticmethod
@ -124,21 +179,51 @@ class HeidiClient(discord.Client):
# await message.add_reaction("❤") # await message.add_reaction("❤")
@staticmethod @staticmethod
async def _autoreact_to_jeremy(message): async def _autoreact_to_jeremy(message: discord.Message):
""" """
🧀 Jeremy 🧀 Jeremy
""" """
await message.add_reaction("🧀") await message.add_reaction("🧀")
@staticmethod
async def _autoreact_to_kardashian(message: discord.Message):
"""
💄 Kardashian
"""
await message.add_reaction("💄")
async def _play_entrance_sound(
self,
member: discord.Member,
before: discord.VoiceState,
after: discord.VoiceState,
):
soundpath: Union[str, None] = self.user_config["ENTRANCE.SOUND"].get(
member.name, None
)
if soundpath == None:
print(f"User {member.name} has not set an entrance sound")
return
board, sound = soundpath.split("/")
# Wait a bit to not have simultaneous joins
await asyncio.sleep(1)
await play_voice_line_for_member(None, member, board, sound)
# ------------------------------------------------------------------------------------------------ # ------------------------------------------------------------------------------------------------
# Log to file # Log to file
handler = logging.FileHandler(filename='discord.log', encoding='utf-8', mode='w') handler = logging.FileHandler(filename="discord.log", encoding="utf-8", mode="w")
# Intents specification is no longer optional # Intents specification is no longer optional
intents = discord.Intents.default() intents = discord.Intents.default()
intents.members = True # Allow to react to member join/leave etc intents.members = True # Allow to react to member join/leave etc
intents.message_content = True # Allow to read message content from arbitrary messages intents.message_content = True # Allow to read message content from arbitrary messages
intents.voice_states = True # Allow to process on_voice_state_update
# Setup our client # Setup our client
client = HeidiClient(intents=intents) client = HeidiClient(intents=intents)
@ -146,32 +231,145 @@ client = HeidiClient(intents=intents)
# Events ----------------------------------------------------------------------------------------- # Events -----------------------------------------------------------------------------------------
# NOTE: I defined the events outside of the Client class, don't know if I like it or not... # NOTE: I defined the events outside of the Client class, don't know if I like it or not...
@client.event
async def on_ready():
print(f"{client.user} (id: {client.user.id}) has connected to Discord!")
@client.event @client.event
async def on_message(message): async def on_ready():
if client.user != None:
print(f"{client.user} (id: {client.user.id}) has connected to Discord!")
else:
print("client.user is None!")
@client.event
async def on_message(message: discord.Message):
# Skip Heidis own messages # Skip Heidis own messages
if message.author == client.user: if message.author == client.user:
return return
# Automatic actions for all messages # Automatic actions for all messages
# python iterates over the keys of a map # python iterates over the keys of a map
for predicate in client.auto_triggers: for predicate in client.on_message_triggers:
if predicate(message): if predicate(message):
action = client.auto_triggers[predicate] action = client.on_message_triggers[predicate]
print(f"on_message: calling {action.__name__}")
await action(message) await action(message)
break
@client.event
async def on_voice_state_update(
member: discord.Member, before: discord.VoiceState, after: discord.VoiceState
):
# Skip Heidis own voice state updates (e.g. on /say)
if member._user == client.user:
return
# Automatic acions for all voice state changes
# python iterates over the keys of a map
for predicate in client.on_voice_state_triggers:
if predicate(member, before, after):
action = client.on_voice_state_triggers[predicate]
print(f"on_voice_state_update: calling {action.__name__}")
await action(member, before, after)
# Config Commands --------------------------------------------------------------------------------
async def user_config_key_autocomplete(
interaction: discord.Interaction, current: str
) -> List[Choice[str]]:
return [
Choice(name=key, value=key)
for key in client.user_config.sections()
if key.lower().startswith(current.lower())
]
async def user_config_value_autocomplete(
interaction: discord.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: discord.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: list(map(lambda x: x.split(".")[0], os.listdir(f"{SOUNDDIR}/{board}/")))
for board in boards
} # These are all sounds, organized per board
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, value=soundpath)]
return completions
@client.tree.command(
name="userconfig",
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)
@app_commands.rename(config_value="wert")
@app_commands.describe(
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: discord.Interaction, config_key: str, config_value: str
):
# Only Members can set settings
if not isinstance(interaction.user, discord.Member):
print("User not a member")
await interaction.response.send_message("Heidi sagt: Komm in die Gruppe!")
return
member: discord.Member = interaction.user
client.user_config[config_key][member.name] = config_value
client.write_user_config()
await interaction.response.send_message(
f"Ok, ich schreibe {member.name}={config_value} in mein fettes Gehirn!"
)
# Commands --------------------------------------------------------------------------------------- # Commands ---------------------------------------------------------------------------------------
@client.tree.command(name = "giblinkbruder", description = "Heidi hilft mit dem Link zu deiner Lieblingsshow im Qualitätsfernsehen.")
@client.tree.command(
name="giblinkbruder",
description="Heidi hilft mit dem Link zu deiner Lieblingsshow im Qualitätsfernsehen.",
)
async def show_link(interaction: discord.Interaction): async def show_link(interaction: discord.Interaction):
link_pro7 = "https://www.prosieben.de/tv/germanys-next-topmodel/livestream" link_pro7 = "https://www.prosieben.de/tv/germanys-next-topmodel/livestream"
link_joyn = "https://www.joyn.de/serien/germanys-next-topmodel" link_joyn = "https://www.joyn.de/serien/germanys-next-topmodel"
await interaction.response.send_message(f"ProSieben: {link_pro7}\nJoyn: {link_joyn}") await interaction.response.send_message(
f"ProSieben: {link_pro7}\nJoyn: {link_joyn}"
)
@client.tree.command(name="heidi", description="Heidi!") @client.tree.command(name="heidi", description="Heidi!")
async def heidi_exclaim(interaction: discord.Interaction): async def heidi_exclaim(interaction: discord.Interaction):
@ -182,13 +380,14 @@ async def heidi_exclaim(interaction: discord.Interaction):
"Dann zieh dich mal aus!", "Dann zieh dich mal aus!",
"Warum denn so schüchtern?", "Warum denn so schüchtern?",
"Im TV ist das legal!", "Im TV ist das legal!",
"Das Stroh ist nur fürs Shooting!" "Das Stroh ist nur fürs Shooting!",
] ]
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?") @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!")
async def magic_shell(interaction: discord.Interaction, question: str): async def magic_shell(interaction: discord.Interaction, question: str):
choices = [ choices = [
"Ja!", "Ja!",
@ -202,17 +401,26 @@ async def magic_shell(interaction: discord.Interaction, question: str):
] ]
question = question.strip() question = question.strip()
question_mark = "" if question[-1] == "?" else "?" 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!") @client.tree.command(name="wähle", description="Heidi trifft die Wahl!")
@app_commands.rename(option_a = "entweder") @app_commands.rename(option_a="entweder")
@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?")
async def choose(interaction: discord.Interaction, option_a: str, option_b: str): async def choose(interaction: discord.Interaction, option_a: str, option_b: str):
options = [option_a.strip(), option_b.strip()] options = [option_a.strip(), option_b.strip()]
await interaction.response.send_message(f"{options[0]} oder {options[1]}?\nHeidi sagt: {random.choice(options)}") await interaction.response.send_message(
f"{options[0]} oder {options[1]}?\nHeidi sagt: {random.choice(options)}"
)
# TextGen ----------------------------------------------------------------------------------------
# async def quote_model_autocomplete(interaction: discord.Interaction, current: str) -> list[Choice[str]]: # async def quote_model_autocomplete(interaction: discord.Interaction, current: str) -> list[Choice[str]]:
# models = client.textgen_models.keys() # models = client.textgen_models.keys()
@ -239,22 +447,47 @@ async def choose(interaction: discord.Interaction, option_a: str, option_b: str)
# joined_quote = " ".join(generated_quote) # joined_quote = " ".join(generated_quote)
# await interaction.response.send_message(f"Heidi sagt: \"{joined_quote}\"") # await interaction.response.send_message(f"Heidi sagt: \"{joined_quote}\"")
SOUNDDIR: str = "/sounds/" if DOCKER else "./voicelines/"
# Sounds -----------------------------------------------------------------------------------------
SOUNDDIR: str = "/sounds" if DOCKER else "./heidi-sounds"
# Example: https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=autocomplete#discord.app_commands.autocomplete # Example: https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=autocomplete#discord.app_commands.autocomplete
async def board_autocomplete(interaction: discord.Interaction, current: str) -> list[Choice[str]]: async def board_autocomplete(
boards = os.listdir(SOUNDDIR) interaction: discord.Interaction, current: str
return [Choice(name=board, value=board) for board in boards] ) -> List[Choice[str]]:
boards: List[str] = os.listdir(SOUNDDIR)
async def sound_autocomplete(interaction: discord.Interaction, current: str) -> list[Choice[str]]: return [
board = interaction.namespace.board # TODO: Can't work? Choice(name=board, value=board)
sounds = map(lambda x: x.split(".")[0], os.listdir(SOUNDDIR + board + "/")) for board in boards
return [Choice(name=sound, value=sound) for sound in sounds] if board.lower().startswith(current.lower())
]
@client.tree.command(name = "sag", description = "Heidi drückt den Knopf auf dem Soundboard.")
@app_commands.describe(sound = "Was soll Heidi sagen?") async def sound_autocomplete(
@app_commands.autocomplete(board = board_autocomplete) interaction: discord.Interaction, current: str
@app_commands.autocomplete(sound = sound_autocomplete) ) -> List[Choice[str]]:
board: str = interaction.namespace.board
sounds: List[str] = list(
map(lambda x: x.split(".")[0], os.listdir(f"{SOUNDDIR}/{board}/"))
)
return [
Choice(name=sound, 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."
)
@app_commands.describe(sound="Was soll Heidi sagen?")
@app_commands.autocomplete(board=board_autocomplete)
@app_commands.autocomplete(sound=sound_autocomplete)
async def say_voiceline(interaction: discord.Interaction, board: str, sound: str): async def say_voiceline(interaction: discord.Interaction, board: str, sound: str):
# Only Members can access voice channels # Only Members can access voice channels
if not isinstance(interaction.user, discord.Member): if not isinstance(interaction.user, discord.Member):
@ -264,38 +497,17 @@ async def say_voiceline(interaction: discord.Interaction, board: str, sound: str
member: discord.Member = interaction.user member: discord.Member = interaction.user
# Member needs to be in voice channel to hear audio (Heidi needs to know the channel to join) await play_voice_line_for_member(interaction, member, board, sound)
if (not member.voice) or (not member.voice.channel) or (not isinstance(member.voice.channel, discord.VoiceChannel)):
print("User not in (valid) voice channel!")
await interaction.response.send_message("Heidi sagt: Komm in den Channel!")
return
voice_channel: discord.VoiceChannel = member.voice.channel
try:
open(SOUNDDIR + board + "/" + sound + ".mkv")
except IOError:
print("Error: Invalid soundfile!")
await interaction.response.send_message(f"Heidi sagt: \"{board}/{sound}\" kanninich finden bruder")
return
await interaction.response.send_message(f"Heidi sagt: \"{board}/{sound}\"")
audio_source = discord.FFmpegPCMAudio(SOUNDDIR + board + "/" + sound + ".mkv") # only works from docker
voice_client = await voice_channel.connect()
voice_client.play(audio_source)
while voice_client.is_playing():
await asyncio.sleep(1)
await voice_client.disconnect()
# Contextmenu ------------------------------------------------------------------------------------ # Contextmenu ------------------------------------------------------------------------------------
# TODO: More insults
# Callable on members # Callable on members
@client.tree.context_menu(name="beleidigen") @client.tree.context_menu(name="beleidigen")
async def insult(interaction: discord.Interaction, member: discord.Member): # with message: discord.Message this can be called on a message async def insult(
interaction: discord.Interaction, member: discord.Member
): # with message: discord.Message this can be called on a message
if not member.dm_channel: if not member.dm_channel:
await member.create_dm() await member.create_dm()
@ -317,20 +529,81 @@ async def insult(interaction: discord.Interaction, member: discord.Member): # wi
"Opfer!", "Opfer!",
"Du miese Raupe!", "Du miese Raupe!",
"Geh Steckdosen befruchten!", "Geh Steckdosen befruchten!",
"Richtiger Gesichtsgünther ey!" "Richtiger Gesichtsgünther ey!",
] ]
await member.dm_channel.send(random.choice(insults)) await member.dm_channel.send(random.choice(insults))
await interaction.response.send_message("Anzeige ist raus!") # with ephemeral = True only the caller can see the answer await interaction.response.send_message(
"Anzeige ist raus!"
) # with ephemeral = True only the caller can see the answer
# Helpers ----------------------------------------------------------------------------------------
async def play_voice_line(
interaction: Union[discord.Interaction, None],
voice_channel: discord.VoiceChannel,
board: str,
sound: str,
):
try:
open(f"{SOUNDDIR}/{board}/{sound}.mkv")
except IOError:
print("Error: Invalid soundfile!")
if interaction != None:
await interaction.response.send_message(
f'Heidi sagt: "{board}/{sound}" kanninich finden bruder'
)
return
if interaction != None:
await interaction.response.send_message(f'Heidi sagt: "{board}/{sound}"')
audio_source = discord.FFmpegPCMAudio(
f"{SOUNDDIR}/{board}/{sound}.mkv"
) # only works from docker
voice_client = await voice_channel.connect()
voice_client.play(audio_source)
while voice_client.is_playing():
await asyncio.sleep(1)
await voice_client.disconnect()
async def play_voice_line_for_member(
interaction: Union[discord.Interaction, None],
member: discord.Member,
board: str,
sound: str,
):
# Member needs to be in voice channel to hear audio (Heidi needs to know the channel to join)
if (
member == None
or member.voice == None
or member.voice.channel == None
or not isinstance(member.voice.channel, discord.VoiceChannel)
):
print("User not in (valid) voice channel!")
if interaction != None:
await interaction.response.send_message("Heidi sagt: Komm in den Channel!")
return
voice_channel: discord.VoiceChannel = member.voice.channel
await play_voice_line(interaction, voice_channel, board, sound)
# ------------------------------------------------------------------------------------------------ # ------------------------------------------------------------------------------------------------
# Used to start the bot locally, for docker the variables have to be set when the container is run # Used to start the bot locally, for docker the variables have to be set when the container is run
load_dotenv()
TOKEN: str = os.getenv("DISCORD_TOKEN") or "" TOKEN: str = os.getenv("DISCORD_TOKEN") or ""
# Start client if TOKEN valid # Start client if TOKEN valid
if TOKEN != "": if TOKEN != "":
print(f"Running client with DOCKER={DOCKER}")
client.run(TOKEN, log_handler=handler) client.run(TOKEN, log_handler=handler)
else: else:
print("DISCORD_TOKEN not found, exiting...") print("DISCORD_TOKEN not found, exiting...")

76
flake.lock generated
View File

@ -2,15 +2,15 @@
"nodes": { "nodes": {
"devshell": { "devshell": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "nixpkgs": "nixpkgs",
"nixpkgs": "nixpkgs" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1666548262, "lastModified": 1700815693,
"narHash": "sha256-4DyN4KXqQQsCw0vCXkMThw4b5Q4/q87ZZgRb4st8COc=", "narHash": "sha256-JtKZEQUzosrCwDsLgm+g6aqbP1aseUl1334OShEAS3s=",
"owner": "numtide", "owner": "numtide",
"repo": "devshell", "repo": "devshell",
"rev": "c8ce8ed81726079c398f5f29c4b68a7d6a3c2fa2", "rev": "7ad1c417c87e98e56dcef7ecd0e0a2f2e5669d51",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -20,27 +20,15 @@
} }
}, },
"flake-utils": { "flake-utils": {
"locked": { "inputs": {
"lastModified": 1642700792, "systems": "systems_2"
"narHash": "sha256-XqHrk7hFb+zBvRg6Ghl+AZDq03ov6OshJLiSWOoX5es=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "846b2ae0fc4cc943637d3d1def4454213e203cba",
"type": "github"
}, },
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"locked": { "locked": {
"lastModified": 1659877975, "lastModified": 1694529238,
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", "rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -51,11 +39,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1643381941, "lastModified": 1677383253,
"narHash": "sha256-pHTwvnN4tTsEKkWlXQ8JMY423epos8wUOhthpwJjtpc=", "narHash": "sha256-UfpzWfSxkfXHnb4boXZNaKsAcUrZT9Hw+tao1oZxd08=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "5efc8ca954272c4376ac929f4c5ffefcc20551d5", "rev": "9952d6bc395f5841262b006fbace8dd7e143b634",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -67,11 +55,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1666926733, "lastModified": 1700856099,
"narHash": "sha256-+gYfOEnQVISPDRNoWm2VJD5OEuTUySt48RchLpvm61o=", "narHash": "sha256-RnEA7iJ36Ay9jI0WwP+/y4zjEhmeN6Cjs9VOFBH7eVQ=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "f44ba1be526c8da9e79a5759feca2365204003f6", "rev": "0bd59c54ef06bc34eca01e37d689f5e46b3fe2f1",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -84,9 +72,39 @@
"root": { "root": {
"inputs": { "inputs": {
"devshell": "devshell", "devshell": "devshell",
"flake-utils": "flake-utils_2", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
} }
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

View File

@ -11,7 +11,7 @@
pkgs = import nixpkgs { pkgs = import nixpkgs {
inherit system; inherit system;
config.allowUnfree = true; config.allowUnfree = true;
overlays = [ devshell.overlay ]; overlays = [ devshell.overlays.default ];
}; };
# TODO: Originally it was nixpkgs.fetchurl but that didn't work, pkgs.fetchurl did... # TODO: Originally it was nixpkgs.fetchurl but that didn't work, pkgs.fetchurl did...
@ -35,7 +35,7 @@
}; };
})); }));
myPython = pkgs.python310.withPackages (p: with p; [ myPython = pkgs.python311.withPackages (p: with p; [
# Basic # Basic
rich rich
@ -45,15 +45,15 @@
pynacl pynacl
# Scraping # Scraping
beautifulsoup4 # beautifulsoup4
requests # requests
# MachineLearning # MachineLearning
torch-rocm # torch-rocm
torchvision-rocm # torchvision-rocm
numpy # numpy
matplotlib # matplotlib
nltk # nltk
]); ]);
in { in {
devShell = pkgs.devshell.mkShell { devShell = pkgs.devshell.mkShell {
@ -61,7 +61,7 @@
packages = with pkgs; [ packages = with pkgs; [
myPython myPython
nodePackages.pyright # LSP # nodePackages.pyright # LSP
]; ];
# Use $1 for positional args # Use $1 for positional args

BIN
heidi-sounds/kaptn/Discord Call.mkv (Stored with Git LFS) Normal file

Binary file not shown.

BIN
heidi-sounds/kaptn/FBI open up.mkv (Stored with Git LFS) Normal file

Binary file not shown.

BIN
heidi-sounds/kaptn/Kleine Spende Bitte.mkv (Stored with Git LFS) Normal file

Binary file not shown.

BIN
heidi-sounds/kaptn/Meine Chicken Nuggets Verbrennen.mkv (Stored with Git LFS) Normal file

Binary file not shown.

BIN
heidi-sounds/kaptn/Nebenrisiken.mkv (Stored with Git LFS) Normal file

Binary file not shown.