From af14b7074a20d75d0f46b9328ac4462e302fe2e5 Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Tue, 26 Mar 2024 16:53:04 -0500 Subject: [PATCH 01/15] Testing pomice for handling audio --- README.md | 5 ++++- modules/audio.py | 11 +++++------ requirements.txt | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5e5992c..56452cf 100644 --- a/README.md +++ b/README.md @@ -26,4 +26,7 @@ The scheduling module is used for tasks related to Notion and Twitch. Pulling sc Module for text generation. Currently using chatGPT, but intent is to move to add ability to use self-hosted LLM. Should be able to choose between something the user is hosting or commercially available alternatives. Also handles simple randomized snarky replies. -This project is licensed under the terms of the MIT license. \ No newline at end of file +This project is licensed under the terms of the MIT license. + + +> Note: Dolores is largely a personal project created for a few small Discord servers. So there's a number of features or peculiarities that specifically deal with things unique to what we want her to do. Should still be useful in a broader more generalized context, but I will work over time to make her less specific. Or at least make her uses more configurable. \ No newline at end of file diff --git a/modules/audio.py b/modules/audio.py index 1a433fd..16218cc 100644 --- a/modules/audio.py +++ b/modules/audio.py @@ -5,14 +5,13 @@ import asyncio import discord -import yt_dlp -from discord.ext import bridge, commands - +import pomice from configload import config +from discord.ext import bridge, commands -yt_dlp.utils.bug_reports_message = lambda: "" -ffmpeg_options = {"options": "-vn"} -ytdl = yt_dlp.YoutubeDL(config["YTDL"]) +# yt_dlp.utils.bug_reports_message = lambda: "" +# ffmpeg_options = {"options": "-vn"} +# ytdl = yt_dlp.YoutubeDL(config["YTDL"]) class YTDLSource(discord.PCMVolumeTransformer): diff --git a/requirements.txt b/requirements.txt index 6af31b6..cb11c44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ requests==2.31.0 pyyaml==6.0.1 -yt-dlp==2024.3.10 py-cord[voice]==2.5.0 openai==1.13.3 apprise==1.7.4 +pomice==2.9.0 From 7530162a6de5d7ec08612b03efd6c85668fa370d Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Fri, 12 Apr 2024 16:45:09 -0500 Subject: [PATCH 02/15] Adding node setup --- config/example_config.yml | 32 +++++++++++++++++++++++++---- modules/audio.py | 43 +++++++++++---------------------------- 2 files changed, 40 insertions(+), 35 deletions(-) diff --git a/config/example_config.yml b/config/example_config.yml index 48500bd..7530fcd 100644 --- a/config/example_config.yml +++ b/config/example_config.yml @@ -2,12 +2,32 @@ DISCORD: bot_api_key: BOT_API_KEY test_bot_api_key: TEST_BOT_API_KEY - sarcastic_names: ['my lovely','darling','sweetie','sweetie-pie','my sugar lump princess','my big strong warrior','dearest','lover','honey','foxy mama','loathsome dung eater','baby girl','Felicia','mamacita','diva','hunty','Queen','Jan'] + sarcastic_names: + [ + "my lovely", + "darling", + "sweetie", + "sweetie-pie", + "my sugar lump princess", + "my big strong warrior", + "dearest", + "lover", + "honey", + "foxy mama", + "loathsome dung eater", + "baby girl", + "Felicia", + "mamacita", + "diva", + "hunty", + "Queen", + "Jan", + ] reply_method: openai news_channel_id: channel id you want Doloresto watch for summarization YTDL: format: bestaudio/best - outtmpl: '%(extractor)s-%(id)s-%(title)s.%(ext)s' + outtmpl: "%(extractor)s-%(id)s-%(title)s.%(ext)s" restrictfilenames: True noplaylist: True nocheckcertificate: True @@ -16,11 +36,11 @@ YTDL: quiet: True no_warnings: True default_search: auto - source_address: '0.0.0.0' + source_address: "0.0.0.0" NOTION: base_url: https://api.notion.com/v1/ api_key: API_KEY - notion_version: '2022-06-28' + notion_version: "2022-06-28" database_id: NOTION_DATABASE_ID_WITH_CALENDAR TWITCH: client_id: TWITCH_CLIENT_ID @@ -42,3 +62,7 @@ SELFHOST: SMMRY: api_key: API_KEY base_url: https://api.smmry.com +LAVALINK: + host: 0.0.0.0 + port: 1234 + password: password diff --git a/modules/audio.py b/modules/audio.py index 16218cc..cbcbf58 100644 --- a/modules/audio.py +++ b/modules/audio.py @@ -6,39 +6,9 @@ import discord import pomice -from configload import config from discord.ext import bridge, commands -# yt_dlp.utils.bug_reports_message = lambda: "" -# ffmpeg_options = {"options": "-vn"} -# ytdl = yt_dlp.YoutubeDL(config["YTDL"]) - - -class YTDLSource(discord.PCMVolumeTransformer): - """ - The YTDLSource class represents an individual source of music. - """ - - def __init__(self, source, *, data, volume=0.5): - super().__init__(source, volume) - self.data = data - self.title = data.get("title") - self.url = data.get("url") - - @classmethod - async def from_url(cls, url, *, loop=None, stream=False): - """ - from_url pulls in the actual audio data from a given URL - """ - loop = loop or asyncio.get_event_loop() - data = await loop.run_in_executor( - None, lambda: ytdl.extract_info(url, download=not stream) - ) - if "entries" in data: - # take first item from a playlist - data = data["entries"][0] - filename = data["url"] if stream else ytdl.prepare_filename(data) - return cls(discord.FFmpegPCMAudio(filename, **ffmpeg_options), data=data) +from configload import config class audio(commands.Cog): @@ -48,6 +18,17 @@ class audio(commands.Cog): def __init__(self, bot): self.bot = bot + self.pomice = pomice.NodePool() + self.queue = pomice.Queue() + + async def start_nodes(self): + await self.pomice.create_node( + bot=self.bot, + host=config["LAVALINK"]["host"], + port=config["LAVALINK"]["port"], + password=config["LAVALINK"]["password"], + identifier="Dolores", + ) @bridge.bridge_command( description="Use's yt-dlp to play an audio stream in the user's voice channel." From e87e6fb4ca44db91b8d5d842a8507e5ee1badb40 Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Mon, 3 Jun 2024 16:50:42 -0500 Subject: [PATCH 03/15] Started adding funcs per pomice documentation --- dockerfile | 1 - modules/audio.py | 201 +++++++++++++++++++++++++++++++++++++++-------- requirements.txt | 1 - 3 files changed, 167 insertions(+), 36 deletions(-) diff --git a/dockerfile b/dockerfile index 481375c..6129f01 100644 --- a/dockerfile +++ b/dockerfile @@ -1,5 +1,4 @@ FROM python:3.12 -RUN apt-get -y update && apt-get install -y ffmpeg RUN python -m pip install --upgrade pip RUN mkdir /home/dolores COPY . /home/dolores diff --git a/modules/audio.py b/modules/audio.py index cbcbf58..409d06a 100644 --- a/modules/audio.py +++ b/modules/audio.py @@ -3,6 +3,7 @@ """ import asyncio +from contextlib import suppress import discord import pomice @@ -11,6 +12,73 @@ from configload import config +class Player(pomice.Player): + """Custom pomice Player class.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.queue = pomice.Queue() + self.controller: discord.Message = None + # Set context here so we can send a now playing embed + self.context: commands.Context = None + self.dj: discord.Member = None + + self.pause_votes = set() + self.resume_votes = set() + self.skip_votes = set() + self.shuffle_votes = set() + self.stop_votes = set() + + async def do_next(self) -> None: + # Clear the votes for a new song + self.pause_votes.clear() + self.resume_votes.clear() + self.skip_votes.clear() + self.shuffle_votes.clear() + self.stop_votes.clear() + + # Check if theres a controller still active and deletes it + if self.controller: + with suppress(discord.HTTPException): + await self.controller.delete() + + # Queue up the next track, else teardown the player + try: + track: pomice.Track = self.queue.get() + except pomice.QueueEmpty: + return await self.teardown() + + await self.play(track) + + # Call the controller (a.k.a: The "Now Playing" embed) and check if one exists + + if track.is_stream: + embed = discord.Embed( + title="Now playing", + description=f":red_circle: **LIVE** [{track.title}]({track.uri}) [{track.requester.mention}]", + ) + self.controller = await self.context.send(embed=embed) + else: + embed = discord.Embed( + title=f"Now playing", + description=f"[{track.title}]({track.uri}) [{track.requester.mention}]", + ) + self.controller = await self.context.send(embed=embed) + + async def teardown(self): + """Clear internal states, remove player controller and disconnect.""" + with suppress((discord.HTTPException), (KeyError)): + await self.destroy() + if self.controller: + await self.controller.delete() + + async def set_context(self, ctx: commands.Context): + """Set context for the player""" + self.context = ctx + self.dj = ctx.author + + class audio(commands.Cog): """ The audio class contains all commands related to playing audio. @@ -31,37 +99,114 @@ async def start_nodes(self): ) @bridge.bridge_command( - description="Use's yt-dlp to play an audio stream in the user's voice channel." + description="Joins the voice channel of the user who called the command." ) - async def play(self, ctx, *, url): + async def join( + self, ctx: commands.Context, *, channel: discord.VoiceChannel = None + ) -> None: + """ + Joins the voice channel of the user who called the command. + """ + if not channel: + channel = getattr(ctx.author.voice, "channel", None) + if not channel: + raise commands.CheckFailure( + "You must be in a voice channel to use this command " + "without specifying the channel argument.", + ) + await ctx.author.voice.channel.connect(cls=pomice.Player) + await ctx.send(f"Joined the voice channel `{channel}`") + + @bridge.bridge_command(description="Disconnects Dolores from voice channel.") + async def leave(self, ctx): + """ + Disconnects Dolores from voice chat channel, if she is connected. + Also stops any currently playing music + Ex: -leave + """ + if not ctx.voice_client: + raise commands.CommandError("No player detected") + + player: pomice.Player = ctx.voice_client + + await player.destroy() + await ctx.send("Dolores has left the building.") + + @bridge.bridge_command( + description="Use's pomice/lavalink to play an audio stream in the user's voice channel." + ) + async def play(self, ctx: commands.Context, *, search: str) -> None: """ Plays a song from a given URL in the user's current voice channel. Valid URLS are Youtube and Soundcloud Ex: -play https://www.youtube.com/watch?v=O1OTWCd40bc Dolores will play Wicked Games by The Weeknd """ - await ctx.defer() - member = ctx.guild.get_member(ctx.author.id) - try: - channel = member.voice.channel - if channel and ctx.voice_client is None: - voice = await channel.connect() - except AttributeError: - await ctx.respond("Must be connected to voice channel to play audio.") + # Checks if the player is in the channel before we play anything + if not (player := ctx.voice_client): + await ctx.author.voice.channel.connect(cls=Player) + player: Player = ctx.voice_client + await player.set_context(ctx=ctx) - if ctx.voice_client.is_playing(): - ctx.voice_client.stop() + # If you search a keyword, Pomice will automagically search the result using YouTube + # You can pass in "search_type=" as an argument to change the search type + # i.e: player.get_tracks("query", search_type=SearchType.ytmsearch) + # will search up any keyword results on YouTube Music - player = await YTDLSource.from_url(url, loop=self.bot.loop, stream=False) - voice.play( - player, after=lambda e: print("Player error: {}".format(e)) if e else None - ) - await ctx.respond("Now playing: {}".format(player.title)) - while True: - await asyncio.sleep(5) - if not ctx.voice_client.is_playing(): - await ctx.voice_client.disconnect() - break + # We will also set the context here to get special features, like a track.requester object + results = await player.get_tracks(search, ctx=ctx) + + if not results: + await ctx.send("No results were found for that search term", delete_after=7) + + if isinstance(results, pomice.Playlist): + for track in results.tracks: + player.queue.put(track) + else: + track = results[0] + player.queue.put(track) + + if not player.is_playing: + await player.do_next() + + @bridge.bridge_command(aliases=["n", "nex", "next", "sk"]) + async def skip(self, ctx: commands.Context): + """ + Skip the currently playing song. + """ + if not (player := ctx.voice_client): + return await ctx.send( + "You must have the bot in a channel in order to use this command", + delete_after=7, + ) + + if not player.is_connected: + return + + if self.is_privileged(ctx): + await ctx.send("An admin or DJ has skipped the song.", delete_after=10) + player.skip_votes.clear() + + return await player.stop() + + if ctx.author == player.current.requester: + await ctx.send("The song requester has skipped the song.", delete_after=10) + player.skip_votes.clear() + + return await player.stop() + + required = self.required(ctx) + player.skip_votes.add(ctx.author) + + if len(player.skip_votes) >= required: + await ctx.send("Vote to skip passed. Skipping song.", delete_after=10) + player.skip_votes.clear() + await player.stop() + else: + await ctx.send( + f"{ctx.author.mention} has voted to skip the song. Votes: {len(player.skip_votes)}/{required} ", + delete_after=15, + ) @bridge.bridge_command(description="Stops the currently playing audio.") async def stop(self, ctx): @@ -72,15 +217,3 @@ async def stop(self, ctx): if ctx.voice_client.is_playing(): ctx.voice_client.stop() await ctx.respond("Stopped playing.") - - @bridge.bridge_command(description="Disconnects Dolores from voice channel.") - async def leave(self, ctx): - """ - Disconnects Dolores from voice chat channel, if she is connected. - Also stops any currently playing music - Ex: -leave - """ - if ctx.voice_client.is_playing(): - ctx.voice_client.stop() - await ctx.voice_client.disconnect() - await ctx.respond("Disconnected from voice channel.") diff --git a/requirements.txt b/requirements.txt index e94a089..8e0e93e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ requests==2.32.3 pyyaml==6.0.1 -yt-dlp==2024.5.27 py-cord[voice]==2.5.0 openai==1.30.5 apprise==1.8.0 From 377eec1ebe4e45b710618109b7dd70e454618c44 Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Tue, 4 Jun 2024 16:46:11 -0500 Subject: [PATCH 04/15] Initial compose file (not complete) --- compose.yml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 compose.yml diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..8d917ba --- /dev/null +++ b/compose.yml @@ -0,0 +1,40 @@ +name: dolores + +services: + dolores: + image: + container_name: dolores + restart: unless-stopped + + lavalink: + image: ghcr.io/lavalink-devs/lavalink:4 + container_name: lavalink + restart: unless-stopped + environment: + - _JAVA_OPTIONS=-Xmx6G + - SERVER_PORT=2333 + - SERVER_ADDRESS=0.0.0.0 + - SERVER_HTTP2_ENABLED=true + - LAVALINK_SERVER_PASSWORD= + - LAVALINK_SERVER_SOURCES_YOUTUBE=true + - LAVALINK_SERVER_SOURCES_BANDCAMP=false + - LAVALINK_SERVER_SOURCES_SOUNDCLOUD=false + - LAVALINK_SERVER_SOURCES_TWITCH=false + - LAVALINK_SERVER_SOURCES_VIMEO=false + - LAVALINK_SERVER_SOURCES_HTTP=true + - LAVALINK_SERVER_SOURCES_LOCAL=false + - LAVALINK_PLUGINS_DIR=/opt/Lavalink/plugins/ + volumes: + - C:\{Docker folder}\lavalink\plugins/:/opt/Lavalink/plugins/ + networks: + - lavalink + expose: + # lavalink exposes port 2333 to connect to for other containers (this is for documentation purposes only) + - 2333 + ports: + # you only need this if you want to make your lavalink accessible from outside of containers + - "2333:2333" +networks: + # create a lavalink network you can add other containers to, to give them access to Lavalink + lavalink: + name: lavalink From c6832338add7831add83f1607e2372544209f2c0 Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Tue, 16 Jul 2024 15:45:02 -0500 Subject: [PATCH 05/15] Additional work --- modules/audio.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/modules/audio.py b/modules/audio.py index 409d06a..a07fb95 100644 --- a/modules/audio.py +++ b/modules/audio.py @@ -1,5 +1,7 @@ """ audio.py module + +Much has come from the pomice example bot """ import asyncio @@ -115,10 +117,15 @@ async def join( "without specifying the channel argument.", ) await ctx.author.voice.channel.connect(cls=pomice.Player) + + player: Player = ctx.voice_client + + # Set the player context so we can use it so send messages + await player.set_context(ctx=ctx) await ctx.send(f"Joined the voice channel `{channel}`") @bridge.bridge_command(description="Disconnects Dolores from voice channel.") - async def leave(self, ctx): + async def leave(self, ctx: commands.Context) -> None: """ Disconnects Dolores from voice chat channel, if she is connected. Also stops any currently playing music From 2d7fe75ab4ad2a5daa0c789d2b64476b2fafa964 Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Mon, 22 Jul 2024 16:17:52 -0500 Subject: [PATCH 06/15] Work on adding all needed funcs --- modules/audio.py | 121 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 8 deletions(-) diff --git a/modules/audio.py b/modules/audio.py index a07fb95..1de0503 100644 --- a/modules/audio.py +++ b/modules/audio.py @@ -5,13 +5,13 @@ """ import asyncio +import math from contextlib import suppress import discord import pomice -from discord.ext import bridge, commands - from configload import config +from discord.ext import bridge, commands class Player(pomice.Player): @@ -90,8 +90,12 @@ def __init__(self, bot): self.bot = bot self.pomice = pomice.NodePool() self.queue = pomice.Queue() + bot.loop.create_task(self.start_nodes()) async def start_nodes(self): + # Waiting for the bot to get ready before connecting to nodes. + await self.bot.wait_until_ready() + await self.pomice.create_node( bot=self.bot, host=config["LAVALINK"]["host"], @@ -100,6 +104,41 @@ async def start_nodes(self): identifier="Dolores", ) + def required(self, ctx: commands.Context): + """ + Method which returns required votes based on amount of members in a channel. + """ + player: Player = ctx.voice_client + channel = self.bot.get_channel(int(player.channel.id)) + required = math.ceil((len(channel.members) - 1) / 2.5) + + if ctx.command.name == "stop": + if len(channel.members) == 3: + required = 2 + + return required + + def is_privileged(self, ctx: commands.Context): + """ + Check whether the user is an Admin or DJ. + """ + player: Player = ctx.voice_client + + return player.dj == ctx.author or ctx.author.guild_permissions.kick_members + + # Pomice event listeners + @commands.Cog.listener() + async def on_pomice_track_end(self, player: Player, track, _): + await player.do_next() + + @commands.Cog.listener() + async def on_pomice_track_stuck(self, player: Player, track, _): + await player.do_next() + + @commands.Cog.listener() + async def on_pomice_track_exception(self, player: Player, track, _): + await player.do_next() + @bridge.bridge_command( description="Joins the voice channel of the user who called the command." ) @@ -117,7 +156,6 @@ async def join( "without specifying the channel argument.", ) await ctx.author.voice.channel.connect(cls=pomice.Player) - player: Player = ctx.voice_client # Set the player context so we can use it so send messages @@ -125,16 +163,17 @@ async def join( await ctx.send(f"Joined the voice channel `{channel}`") @bridge.bridge_command(description="Disconnects Dolores from voice channel.") - async def leave(self, ctx: commands.Context) -> None: + async def leave(self, ctx: commands.Context): """ Disconnects Dolores from voice chat channel, if she is connected. Also stops any currently playing music Ex: -leave """ - if not ctx.voice_client: - raise commands.CommandError("No player detected") - - player: pomice.Player = ctx.voice_client + if not (player := ctx.voice_client): + return await ctx.send( + "You must have the bot in a channel in order to use this command", + delete_after=7, + ) await player.destroy() await ctx.send("Dolores has left the building.") @@ -176,6 +215,72 @@ async def play(self, ctx: commands.Context, *, search: str) -> None: if not player.is_playing: await player.do_next() + @bridge.bridge_command(description="Pauses the currently playing audio.") + async def pause(self, ctx: commands.Context): + """ + Pauses the currently playing audio + """ + if not (player := ctx.voice_client): + return await ctx.send( + "You must have the bot in a channel in order to use this command", + delete_after=7, + ) + + if player.is_paused or not player.is_connected: + return + + if self.is_privileged(ctx): + await ctx.send("An admin or DJ has paused the player.", delete_after=10) + player.pause_votes.clear() + + return await player.set_pause(True) + + required = self.required(ctx) + player.pause_votes.add(ctx.author) + + if len(player.pause_votes) >= required: + await ctx.send("Vote to pause passed. Pausing player.", delete_after=10) + player.pause_votes.clear() + await player.set_pause(True) + else: + await ctx.send( + f"{ctx.author.mention} has voted to pause the player. Votes: {len(player.pause_votes)}/{required}", + delete_after=15, + ) + + @bridge.bridge_command(description="Resumes the currently paused audio.") + async def resume(self, ctx: commands.Context): + """ + Resumes the currently paused audio + """ + if not (player := ctx.voice_client): + return await ctx.send( + "You must have the bot in a channel in order to use this command", + delete_after=7, + ) + + if not player.is_paused or not player.is_connected: + return + + if self.is_privileged(ctx): + await ctx.send("An admin or DJ has resumed the player.", delete_after=10) + player.resume_votes.clear() + + return await player.set_pause(False) + + required = self.required(ctx) + player.resume_votes.add(ctx.author) + + if len(player.resume_votes) >= required: + await ctx.send("Vote to resume passed. Resuming player.", delete_after=10) + player.resume_votes.clear() + await player.set_pause(False) + else: + await ctx.send( + f"{ctx.author.mention} has voted to resume the player. Votes: {len(player.resume_votes)}/{required}", + delete_after=15, + ) + @bridge.bridge_command(aliases=["n", "nex", "next", "sk"]) async def skip(self, ctx: commands.Context): """ From 66b2a0b30219374fa001edcd955e866bd4bd4807 Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Tue, 23 Jul 2024 09:21:00 -0500 Subject: [PATCH 07/15] Changed to regular commands, rewrite README Seemed to be some conflict between the bridge commands and the new audio functionality. So just decided to move to slash commands entirely. --- .github/workflows/ghcr-publish.yml | 8 ++- README.md | 82 +++++++++++++++++++++--------- compose.yml | 40 --------------- dolores.py | 8 ++- modules/audio.py | 20 ++++---- 5 files changed, 81 insertions(+), 77 deletions(-) delete mode 100644 compose.yml diff --git a/.github/workflows/ghcr-publish.yml b/.github/workflows/ghcr-publish.yml index 70ac9e9..4407d85 100644 --- a/.github/workflows/ghcr-publish.yml +++ b/.github/workflows/ghcr-publish.yml @@ -18,7 +18,9 @@ jobs: uses: docker/metadata-action@v4 with: images: ghcr.io/JMaynor/dolores - tags: type=sha + tags: | + type=sha + type=ref,event=branch,ref=main - name: Login to GHCR uses: docker/login-action@v3 with: @@ -30,4 +32,6 @@ jobs: with: context: . push: true - tags: ${{ steps.meta.outputs.tags }} \ No newline at end of file + tags: | + ${{ steps.meta.outputs.tags }} + ghcr.io/JMaynor/dolores:latest diff --git a/README.md b/README.md index 56452cf..fbc2235 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,68 @@ # Summary -Discord bot named Dolores for rolling dice and a number of other helper functions. - -Dolores can be run easily directly as a python program, but she can also be ran within a Docker container, allowing for easier updating. - -Note: Dolores requires a config.yml file with a bot API key and a couple other details to run. example_config.yml is included to show formatting. Config file is organized by service. The only heading explicitly required to run is the info under DISCORD +Discord bot named Dolores for rolling dice, playing audio, and a number of other helper functions. She was initially created to help facilitiate playing tabletop games over Discord. + +Dolores can be run easily directly as a python program, but she can also be ran within a Docker container, allowing for easier updating. An example `compose.yml` layout is below, assuming the user wants to make use of the lavalink functionality. + +> Note: Dolores is largely a personal project created for a few small Discord servers. So there's a number of features or peculiarities that specifically deal with things unique to what we want her to do. Should still be useful in a broader more generalized context, but I will work over time to make her less specific. Or at least make her uses more configurable. + +## Config + +Dolores requires a config.yml file with a bot API key and a couple other details to run. example_config.yml is included to show formatting. Config file is organized by service. The only heading explicitly required to run is the info under DISCORD. + +> Note: Intention is to move all config to env vars so easier to deploy via a single compose spec. No need for the config file. + +Below is an example docker compose spec. + +```yml +name: Dolores + +services: + dolores: + image: exaltatus/dolores:latest + container_name: dolores + restart: unless-stopped + volumes: + - C:\{Docker folder}\Dolores:/home/dolores/config + lavalink: + image: ghcr.io/lavalink-devs/lavalink:4 + container_name: lavalink + restart: unless-stopped + environment: + - _JAVA_OPTIONS=-Xmx6G + - SERVER_PORT=2333 + - SERVER_ADDRESS=0.0.0.0 + - SERVER_HTTP2_ENABLED=true + - LAVALINK_SERVER_PASSWORD=password + - LAVALINK_SERVER_SOURCES_YOUTUBE=true + - LAVALINK_SERVER_SOURCES_BANDCAMP=false + - LAVALINK_SERVER_SOURCES_SOUNDCLOUD=false + - LAVALINK_SERVER_SOURCES_TWITCH=false + - LAVALINK_SERVER_SOURCES_VIMEO=false + - LAVALINK_SERVER_SOURCES_HTTP=true + - LAVALINK_SERVER_SOURCES_LOCAL=false + - LAVALINK_PLUGINS_DIR=/opt/Lavalink/plugins/ + volumes: + - C:\{Docker folder}\lavalink\plugins/:/opt/Lavalink/plugins/ + networks: + - lavalink + expose: + - 2333 + ports: + - "2333:2333" +``` ## Modules -Dolores' functionality is divided into several cogs modules. dolores.py handles discord events and processing commands. - -### Rolling - -The main module. Used to roll dice and for any other randomization-based tasks. - -### Audio +Dolores' functionality is divided into several cogs modules. `dolores.py` handles main discord events and processing commands. -The audio module uses yt-dlp to download videos and stream the audio into whichever channel the calling user is in. +| Cog | Description | +| --- | ----------- | +| Rolling | Used to roll dice and for any other randomization-based tasks. | +| Audio | The audio module uses pomice/lavalink to stream audio. Uses a queue system. Largely a copy of the example bot given in pomice's documentation. | +| Scheduling | The scheduling module is used for tasks related to Notion and Twitch. Pulling schedule in from a Notion database and posting to twitch schedule. | +| Text | Module for text generation. Currently using chatGPT, but intent is to move to add ability to use self-hosted LLM. Should be able to choose between something the user is hosting or commercially available alternatives. Also handles simple randomized snarky replies. | -### Scheduling - -The scheduling module is used for tasks related to Notion and Twitch. Pulling schedule in from a Notion database and posting to twitch schedule. - -### Text - -Module for text generation. Currently using chatGPT, but intent is to move to add ability to use self-hosted LLM. Should be able to choose between something the user is hosting or commercially available alternatives. Also handles simple randomized snarky replies. +## Licensing This project is licensed under the terms of the MIT license. - - -> Note: Dolores is largely a personal project created for a few small Discord servers. So there's a number of features or peculiarities that specifically deal with things unique to what we want her to do. Should still be useful in a broader more generalized context, but I will work over time to make her less specific. Or at least make her uses more configurable. \ No newline at end of file diff --git a/compose.yml b/compose.yml deleted file mode 100644 index 8d917ba..0000000 --- a/compose.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: dolores - -services: - dolores: - image: - container_name: dolores - restart: unless-stopped - - lavalink: - image: ghcr.io/lavalink-devs/lavalink:4 - container_name: lavalink - restart: unless-stopped - environment: - - _JAVA_OPTIONS=-Xmx6G - - SERVER_PORT=2333 - - SERVER_ADDRESS=0.0.0.0 - - SERVER_HTTP2_ENABLED=true - - LAVALINK_SERVER_PASSWORD= - - LAVALINK_SERVER_SOURCES_YOUTUBE=true - - LAVALINK_SERVER_SOURCES_BANDCAMP=false - - LAVALINK_SERVER_SOURCES_SOUNDCLOUD=false - - LAVALINK_SERVER_SOURCES_TWITCH=false - - LAVALINK_SERVER_SOURCES_VIMEO=false - - LAVALINK_SERVER_SOURCES_HTTP=true - - LAVALINK_SERVER_SOURCES_LOCAL=false - - LAVALINK_PLUGINS_DIR=/opt/Lavalink/plugins/ - volumes: - - C:\{Docker folder}\lavalink\plugins/:/opt/Lavalink/plugins/ - networks: - - lavalink - expose: - # lavalink exposes port 2333 to connect to for other containers (this is for documentation purposes only) - - 2333 - ports: - # you only need this if you want to make your lavalink accessible from outside of containers - - "2333:2333" -networks: - # create a lavalink network you can add other containers to, to give them access to Lavalink - lavalink: - name: lavalink diff --git a/dolores.py b/dolores.py index 6439a2d..3af8250 100644 --- a/dolores.py +++ b/dolores.py @@ -130,8 +130,12 @@ async def on_message(message): """ # If someone mentions Dolores, she will respond to them, - # unless she is the one who sent the message - if (bot.user.mentioned_in(message)) and (message.author.id != bot.user.id): + # unless she is the one who sent the message or it is an @everyone mention + if ( + bot.user.mentioned_in(message) + and message.author.id != bot.user.id + and "@everyone" not in message.clean_content + ): await handle_mention(message) # Check for if message was posted in news channel and contains a non-media URL diff --git a/modules/audio.py b/modules/audio.py index 1de0503..a508d78 100644 --- a/modules/audio.py +++ b/modules/audio.py @@ -10,8 +10,9 @@ import discord import pomice +from discord.ext import commands + from configload import config -from discord.ext import bridge, commands class Player(pomice.Player): @@ -139,7 +140,7 @@ async def on_pomice_track_stuck(self, player: Player, track, _): async def on_pomice_track_exception(self, player: Player, track, _): await player.do_next() - @bridge.bridge_command( + @commands.command( description="Joins the voice channel of the user who called the command." ) async def join( @@ -162,7 +163,7 @@ async def join( await player.set_context(ctx=ctx) await ctx.send(f"Joined the voice channel `{channel}`") - @bridge.bridge_command(description="Disconnects Dolores from voice channel.") + @commands.command(description="Disconnects Dolores from voice channel.") async def leave(self, ctx: commands.Context): """ Disconnects Dolores from voice chat channel, if she is connected. @@ -178,13 +179,12 @@ async def leave(self, ctx: commands.Context): await player.destroy() await ctx.send("Dolores has left the building.") - @bridge.bridge_command( + @commands.command( description="Use's pomice/lavalink to play an audio stream in the user's voice channel." ) async def play(self, ctx: commands.Context, *, search: str) -> None: """ - Plays a song from a given URL in the user's current voice channel. - Valid URLS are Youtube and Soundcloud + Plays audio from a given search term. Ex: -play https://www.youtube.com/watch?v=O1OTWCd40bc Dolores will play Wicked Games by The Weeknd """ @@ -215,7 +215,7 @@ async def play(self, ctx: commands.Context, *, search: str) -> None: if not player.is_playing: await player.do_next() - @bridge.bridge_command(description="Pauses the currently playing audio.") + @commands.command(description="Pauses the currently playing audio.") async def pause(self, ctx: commands.Context): """ Pauses the currently playing audio @@ -248,7 +248,7 @@ async def pause(self, ctx: commands.Context): delete_after=15, ) - @bridge.bridge_command(description="Resumes the currently paused audio.") + @commands.command(description="Resumes the currently paused audio.") async def resume(self, ctx: commands.Context): """ Resumes the currently paused audio @@ -281,7 +281,7 @@ async def resume(self, ctx: commands.Context): delete_after=15, ) - @bridge.bridge_command(aliases=["n", "nex", "next", "sk"]) + @commands.command(description="Skips the currently playing song.") async def skip(self, ctx: commands.Context): """ Skip the currently playing song. @@ -320,7 +320,7 @@ async def skip(self, ctx: commands.Context): delete_after=15, ) - @bridge.bridge_command(description="Stops the currently playing audio.") + @commands.command(description="Stops the currently playing audio.") async def stop(self, ctx): """ Stops the currently playing song, if one is playing. From 2cab9a7336d86a2468260107d2efc241f4cf5bfa Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Tue, 23 Jul 2024 15:01:25 -0500 Subject: [PATCH 08/15] Slash commands, partial config to env var switch Seemed to not be an issue with the bridge commands as I thought, but rather wrong type hint for discord.py. Oh well, keeping it all slash commands if that is the way of the future. https://github.com/Pycord-Development/pycord/issues/1331 Also have started going through and switching from the config.yml file to env vars. Initially planned on doing that afterward, but may as well work on it now if I'm rewriting things. Also have begun going through and redoing readme with that change in mind. --- README.md | 35 +++++++++++++++--- configload.py | 10 ----- dolores.py | 55 +++++++++++++++------------- modules/audio.py | 85 ++++++++++++++++++++++++++++++++----------- modules/rolling.py | 17 +++++---- modules/scheduling.py | 32 ++++++++-------- modules/text.py | 21 +++++------ notify.py | 39 ++++++++++++++------ requirements.txt | 4 +- 9 files changed, 186 insertions(+), 112 deletions(-) delete mode 100644 configload.py diff --git a/README.md b/README.md index fbc2235..a23ed6c 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,42 @@ Discord bot named Dolores for rolling dice, playing audio, and a number of other helper functions. She was initially created to help facilitiate playing tabletop games over Discord. -Dolores can be run easily directly as a python program, but she can also be ran within a Docker container, allowing for easier updating. An example `compose.yml` layout is below, assuming the user wants to make use of the lavalink functionality. +Dolores can be run easily directly as a python program. Generally, I have her running as a Docker container, allows for easier updating. An example `compose.yml` layout is included below, assuming the user wants to make use of all functionality. -> Note: Dolores is largely a personal project created for a few small Discord servers. So there's a number of features or peculiarities that specifically deal with things unique to what we want her to do. Should still be useful in a broader more generalized context, but I will work over time to make her less specific. Or at least make her uses more configurable. +> Note: Dolores is largely a personal project created for a few small Discord servers. So there's a number of features or peculiarities that specifically deal with things unique to what I want her to do. Should still be useful in a broader more generalized context, but I will work over time to make her less specific. Or at least make her uses more configurable. ## Config -Dolores requires a config.yml file with a bot API key and a couple other details to run. example_config.yml is included to show formatting. Config file is organized by service. The only heading explicitly required to run is the info under DISCORD. +Dolores runs using a number of environment variables for API keys and settings. Not all are required for core functionality. Each cog has a corresponding environment variable to turn it on or off. Will determine whether the cog is loaded when Dolores is run. If a cog isn't loaded, none of the environment variables that are associated with it are required. -> Note: Intention is to move all config to env vars so easier to deploy via a single compose spec. No need for the config file. +The only explicitly required environment variable is `DISCORD_API_KEY`. -Below is an example docker compose spec. +| Required by Which Module | Env Var Name | Description | +| --- | --- | --- | +| | | | +| Base | DISCORD_API_KEY | The main API key for the bot. | +| Base | AUDIO_ENABLED | Enables audio cog, when set as true | +| Base | SCHEDULING_ENABLED | Enables scheduling cog, when set as true | +| Base | TEXT_ENABLED | | +| None | APPRISE_ENDPOINTS | Optional comma-separated list of apprise endpoints to send error messages to. otherwise prints to stderr | +| Scheduling | NOTION_API_KEY | API Key for querying data from Notion | +| Scheduling | NOTION_VERSION | Version of Notion API used for querying. | +| Scheduling | NOTION_BASE_URL | Base URL of the Notion API, should be | +| Scheduling | NOTION_DATABASE_ID | ID for database where stream info is kept | +| None | TWITCH_CLIENT_ID | Not yet used | +| None | TWITCH_BASE_URL | Not yet used | +| None | TWITCH_BROADCASTER_ID | Not yet used | +| Text | REPLY_METHOD | Method to use for generating a reply to user's message. At this point only 'openai' is supported. | +| Text | OPENAI_API_KEY | API Key used for generating replies | +| Text | MAX_TOKENS | Max number of tokens generated in LLM chat. | +| Text | SMMRY_BASE_URL | base URL for the SMMRY API. | +| Text | SMMRY_API_KEY | API key for the SMMRY API | +| Text | SMMRY_QUOTE_AVOID | SMMRY boolean option on whether to avoid or include quotes in text that's summarized. Usually true. | +| Text | SMMRY_LENGTH | max number of sentences a summary should be. | + +## Compose + +Below is an example docker compose spec if using all functionality. ```yml name: Dolores diff --git a/configload.py b/configload.py deleted file mode 100644 index 1c34883..0000000 --- a/configload.py +++ /dev/null @@ -1,10 +0,0 @@ -import os - -import yaml - -if os.name == "nt": - CONFIG_FILE = "config\\config.yml" -else: - CONFIG_FILE = "/home/dolores/config/config.yml" -with open(CONFIG_FILE, "r", encoding="utf-8") as c: - config = yaml.safe_load(c) diff --git a/dolores.py b/dolores.py index 3af8250..432a892 100644 --- a/dolores.py +++ b/dolores.py @@ -6,16 +6,10 @@ Dolores is a chatbot that connects to a Discord server. Her primary use is in being able to roll dice for players of a tabletop roleplaying game but she is also capable of doing some basic audio things. - -Majority of functionality has been organized into separate modules. -dolores.py - Main program file. Handles Discord-related functionality and events -audio.py - Handles all audio/yt-dlp related functionality -rolling.py - Handles all dice-rolling/randomization functionality -scheduling.py - Handles all Notion/Twitch scheduling functionality -text.py - Handles all text-related functionality """ import asyncio +import os import re import sys from datetime import datetime @@ -23,19 +17,24 @@ import discord from discord.ext import bridge, commands -from configload import config from modules import * from notify import notif intents = discord.Intents.all() intents.members = True -bot = bridge.Bot(command_prefix="-", case_insensitive=True, intents=intents) +bot = commands.Bot(case_insensitive=True, intents=intents) -# Add all Cog modules +# Add main cog module bot.add_cog(rolling(bot)) -bot.add_cog(audio(bot)) -bot.add_cog(scheduling(bot)) -bot.add_cog(text(bot)) + +# Add modules based on config +# These rely on other dependencies and APIs so only add if enabled +if os.environ["AUDIO_ENABLED"].lower() == "true": + bot.add_cog(audio(bot)) +if os.environ["SCHEDULING_ENABLED"].lower() == "true": + bot.add_cog(scheduling(bot)) +if os.environ["TEXT_ENABLED"].lower() == "true": + bot.add_cog(text(bot)) async def handle_mention(message): @@ -45,12 +44,15 @@ async def handle_mention(message): where the message was posted. """ ctx = await bot.get_context(message) - text_instance = text(bot) - clean_message = message.clean_content.replace("@Dolores", "Dolores") - clean_message = clean_message.replace("@everyone", "everyone") - clean_message = clean_message.replace("@Testie", "Testie") - await ctx.defer() - reply = text_instance.generate_reply(clean_message) + if os.environ["TEXT_ENABLED"].lower() == "true": + text_instance = text(bot) + clean_message = message.clean_content.replace("@Dolores", "Dolores") + clean_message = clean_message.replace("@everyone", "everyone") + clean_message = clean_message.replace("@Testie", "Testie") + await ctx.defer() + reply = text_instance.generate_reply(clean_message) + else: + reply = "Hi" if reply != "": await ctx.respond(reply) @@ -113,12 +115,15 @@ async def on_command_error(ctx, error): comeback. Any other error performs default behavior of logging to syserr. """ await ctx.defer() - text_instance = text(bot) - if isinstance(error, (commands.CommandNotFound)): - await ctx.send(text_instance.generate_snarky_comment()) + if os.environ["TEXT_ENABLED"].lower() == "true": + text_instance = text(bot) + if isinstance(error, (commands.CommandNotFound)): + await ctx.send(text_instance.generate_snarky_comment()) + else: + notif.notify(f"Error: {error}") + print(error, file=sys.stderr) else: - notif.notify(f"Error: {error}") - print(error, file=sys.stderr) + await ctx.send("An error occurred. Please try again.") @bot.event @@ -159,4 +164,4 @@ async def on_message(message): Main program entry point """ print("Starting main program...") - bot.run(config["DISCORD"]["bot_api_key"]) + bot.run(os.environ["DISCORD_API_KEY"]) diff --git a/modules/audio.py b/modules/audio.py index a508d78..8376a8f 100644 --- a/modules/audio.py +++ b/modules/audio.py @@ -6,14 +6,13 @@ import asyncio import math +import os from contextlib import suppress import discord import pomice from discord.ext import commands -from configload import config - class Player(pomice.Player): """Custom pomice Player class.""" @@ -99,9 +98,9 @@ async def start_nodes(self): await self.pomice.create_node( bot=self.bot, - host=config["LAVALINK"]["host"], - port=config["LAVALINK"]["port"], - password=config["LAVALINK"]["password"], + host=os.environ["LAVALINK_HOST"], + port=int(os.environ["LAVALINK_PORT"]), + password=os.environ["LAVALINK_PASSWORD"], identifier="Dolores", ) @@ -140,11 +139,14 @@ async def on_pomice_track_stuck(self, player: Player, track, _): async def on_pomice_track_exception(self, player: Player, track, _): await player.do_next() - @commands.command( + @commands.slash_command( description="Joins the voice channel of the user who called the command." ) async def join( - self, ctx: commands.Context, *, channel: discord.VoiceChannel = None + self, + ctx: discord.commands.context.ApplicationContext, + *, + channel: discord.VoiceChannel = None, ) -> None: """ Joins the voice channel of the user who called the command. @@ -156,15 +158,15 @@ async def join( "You must be in a voice channel to use this command " "without specifying the channel argument.", ) - await ctx.author.voice.channel.connect(cls=pomice.Player) + await ctx.author.voice.channel.connect(cls=Player) player: Player = ctx.voice_client # Set the player context so we can use it so send messages await player.set_context(ctx=ctx) await ctx.send(f"Joined the voice channel `{channel}`") - @commands.command(description="Disconnects Dolores from voice channel.") - async def leave(self, ctx: commands.Context): + @commands.slash_command(description="Disconnects Dolores from voice channel.") + async def leave(self, ctx: discord.commands.context.ApplicationContext): """ Disconnects Dolores from voice chat channel, if she is connected. Also stops any currently playing music @@ -179,10 +181,10 @@ async def leave(self, ctx: commands.Context): await player.destroy() await ctx.send("Dolores has left the building.") - @commands.command( - description="Use's pomice/lavalink to play an audio stream in the user's voice channel." - ) - async def play(self, ctx: commands.Context, *, search: str) -> None: + @commands.slash_command(description="Play audio stream in user's voice channel.") + async def play( + self, ctx: discord.commands.context.ApplicationContext, *, search: str + ) -> None: """ Plays audio from a given search term. Ex: -play https://www.youtube.com/watch?v=O1OTWCd40bc @@ -215,8 +217,8 @@ async def play(self, ctx: commands.Context, *, search: str) -> None: if not player.is_playing: await player.do_next() - @commands.command(description="Pauses the currently playing audio.") - async def pause(self, ctx: commands.Context): + @commands.slash_command(description="Pauses the currently playing audio.") + async def pause(self, ctx: discord.commands.context.ApplicationContext): """ Pauses the currently playing audio """ @@ -248,8 +250,8 @@ async def pause(self, ctx: commands.Context): delete_after=15, ) - @commands.command(description="Resumes the currently paused audio.") - async def resume(self, ctx: commands.Context): + @commands.slash_command(description="Resumes the currently paused audio.") + async def resume(self, ctx: discord.commands.context.ApplicationContext): """ Resumes the currently paused audio """ @@ -281,8 +283,8 @@ async def resume(self, ctx: commands.Context): delete_after=15, ) - @commands.command(description="Skips the currently playing song.") - async def skip(self, ctx: commands.Context): + @commands.slash_command(description="Skips the currently playing song.") + async def skip(self, ctx: discord.commands.context.ApplicationContext): """ Skip the currently playing song. """ @@ -320,8 +322,47 @@ async def skip(self, ctx: commands.Context): delete_after=15, ) - @commands.command(description="Stops the currently playing audio.") - async def stop(self, ctx): + @commands.slash_command(description="Shuffles the queue.") + async def shuffle(self, ctx: discord.commands.context.ApplicationContext): + """ + Shuffles the queue. + """ + if not (player := ctx.voice_client): + return await ctx.send( + "You must have the bot in a channel in order to use this command", + delete_after=7, + ) + if not player.is_connected: + return + + if player.queue.qsize() < 3: + return await ctx.send( + "The queue is empty. Add some songs to shuffle the queue.", + delete_after=15, + ) + + if self.is_privileged(ctx): + await ctx.send("An admin or DJ has shuffled the queue.", delete_after=10) + player.shuffle_votes.clear() + return player.queue.shuffle() + + required = self.required(ctx) + player.shuffle_votes.add(ctx.author) + + if len(player.shuffle_votes) >= required: + await ctx.send( + "Vote to shuffle passed. Shuffling the queue.", delete_after=10 + ) + player.shuffle_votes.clear() + player.queue.shuffle() + else: + await ctx.send( + f"{ctx.author.mention} has voted to shuffle the queue. Votes: {len(player.shuffle_votes)}/{required}", + delete_after=15, + ) + + @commands.slash_command(description="Stops the currently playing audio.") + async def stop(self, ctx: discord.commands.context.ApplicationContext): """ Stops the currently playing song, if one is playing. Ex: -stop diff --git a/modules/rolling.py b/modules/rolling.py index 94d2856..01c2c28 100644 --- a/modules/rolling.py +++ b/modules/rolling.py @@ -1,12 +1,12 @@ """ +The basic module of functionality. Rolls some dice. + rolling.py """ import random -from discord.ext import bridge, commands - -from configload import config +from discord.ext import commands sarcastic_names = config["DISCORD"]["sarcastic_names"] @@ -19,7 +19,7 @@ class rolling(commands.Cog): def __init__(self, bot): self.bot = bot - @bridge.bridge_command( + @commands.slash_command( description="A catch-all command for rolling any number of any-sided dice." ) async def roll(self, ctx, *, dice_batches: str): @@ -59,7 +59,7 @@ async def roll(self, ctx, *, dice_batches: str): ) return - @bridge.bridge_command( + @commands.slash_command( description="A catch-all command for rolling any number of any-sided dice. This one for DMs." ) async def sroll(self, ctx, *, dice_batches: str): @@ -101,7 +101,7 @@ async def sroll(self, ctx, *, dice_batches: str): ) return - @bridge.bridge_command( + @commands.slash_command( description="For when you can't make a simple decision to save your life." ) async def choose(self, ctx, *, choices: str): @@ -113,7 +113,7 @@ async def choose(self, ctx, *, choices: str): await ctx.defer() await ctx.respond(random.choice(choices.split())) - @bridge.bridge_command( + @commands.slash_command( description="Modified dice-roll command to roll a single d20. Short and sweet." ) async def d20(self, ctx): @@ -123,11 +123,12 @@ async def d20(self, ctx): Dolores rolls a single d20 and returns the result. """ await ctx.defer() + # 1 in million chance to roll a goon. if random.randint(1, 1000000) == 1: await ctx.respond("Goon.") await ctx.respond("(d20) " + str(random.randint(1, 20))) - @bridge.bridge_command( + @commands.slash_command( description="Modified dice-roll command to roll a single d20. Short and sweet. Also secret." ) async def sd20(self, ctx): diff --git a/modules/scheduling.py b/modules/scheduling.py index 2ea6d9f..d713834 100644 --- a/modules/scheduling.py +++ b/modules/scheduling.py @@ -9,16 +9,14 @@ import discord import requests -from discord.ext import bridge, commands - -from configload import config +from discord.ext import commands notion_headers = { - "Authorization": "Bearer " + config["NOTION"]["api_key"], - "Notion-Version": config["NOTION"]["notion_version"], + "Authorization": "Bearer " + os.environ["NOTION_API_KEY"], + "Notion-Version": os.environ["NOTION_VERSION"], } -twitch_headers = {"Authorization": "", "Clienti-ID": config["TWITCH"]["client_id"]} +twitch_headers = {"Authorization": "", "Client-ID": os.environ["TWITCH_CLIENT_ID"]} sarcastic_names = config["DISCORD"]["sarcastic_names"] @@ -58,9 +56,9 @@ def get_notion_schedule(self, filter: dict, sorts: list): """ json_data = {"filter": filter, "sorts": sorts} response = requests.post( - config["NOTION"]["base_url"] + os.environ["NOTION_BASE_URL"] + "databases/" - + config["NOTION"]["database_id"] + + os.environ["NOTION_DATABASE_ID"] + "/query", headers=notion_headers, json=json_data, @@ -95,11 +93,11 @@ def get_twitch_schedule( after = "&after=" + after response = requests.get( - config["TWITCH"]["base_url"] + os.environ["TWITCH_BASE_URL"] + "helix/schedule" + "?broadcaster_id=" - + config["TWITCH"]["broadcaster_id"] - + start_time + + os.environ["TWITCH_BROADCASTER_ID"] + + start_time # type: ignore + end_time + first + after, @@ -131,10 +129,10 @@ def add_twitch_segment(self, start_time, is_recurring, category_id, title): } response = requests.post( - config["TWITCH"]["base_url"] + os.environ["TWITCH_BASE_URL"] + "helix/schedule/segment" + "?broadcaster_id=" - + config["TWITCH"]["broadcaster_id"], + + os.environ["TWITCH_BROADCASTER_ID"], json=json_data, headers=twitch_headers, ) @@ -155,10 +153,10 @@ def delete_twitch_segment(self, id): Requires the segment ID """ response = requests.delete( - config["TWITCH"]["base_url"] + os.environ["TWITCH_BASE_URL"] + "helix/schedule/segment" + "?broadcaster_id=" - + config["TWITCH"]["broadcaster_id"] + + os.environ["TWITCH_BROADCASTER_ID"] + "?id=" + id, headers=twitch_headers, @@ -196,7 +194,7 @@ def search_twitch_categories(self, query): Searches for Twitch categories """ response = requests.get( - config["TWITCH"]["base_url"] + os.environ["TWITCH_BASE_URL"] + "helix/search/categories" + "?query=" + urllib.parse.quote(query), @@ -212,7 +210,7 @@ def search_twitch_categories(self, query): return response.json() - @bridge.bridge_command( + @commands.slash_command( description="Returns the next couple streams on the schedule." ) async def schedule(self, ctx): diff --git a/modules/text.py b/modules/text.py index 4442ecc..8c21af0 100644 --- a/modules/text.py +++ b/modules/text.py @@ -2,6 +2,7 @@ text.py module """ +import os import random import sys from collections import deque @@ -9,14 +10,12 @@ import discord import openai import requests -from discord.ext import bridge, commands +from discord.ext import commands -from configload import config - -reply_method = config["DISCORD"]["reply_method"] +reply_method = os.environ["REPLY_METHOD"] if reply_method == "openai": - openai.api_key = config["OPENAI"]["api_key"] + openai.api_key = os.environ["OPENAI_API_KEY"] message_history = deque(maxlen=10) system_messages = [ @@ -44,7 +43,7 @@ def generate_reply(self, message): response = openai.chat.completions.create( model=config["OPENAI"]["model"], messages=system_messages + list(message_history), - max_tokens=config["OPENAI"]["max_tokens"], + max_tokens=int(os.environ["MAX_TOKENS"]), temperature=config["OPENAI"]["temperature"], top_p=config["OPENAI"]["top_p"], frequency_penalty=config["OPENAI"]["frequency_penalty"], @@ -74,13 +73,13 @@ def summarize_url(self, url): Summarizes a given URL using the SMMRY API. """ response = requests.post( - config["SMMRY"]["base_url"] + os.environ["SMMRY_BASE_URL"] + "?SM_API_KEY=" - + str(config["SMMRY"]["api_key"]) + + os.environ["SMMRY_API_KEY"] + "&SM_QUOTE_AVOID=" - + str(config["SMMRY"]["quote_avoid"]).lower() + + os.environ["SMMRY_QUOTE_AVOID"].lower() + "&SM_LENGTH=" - + str(config["SMMRY"]["length"]) + + os.environ["SMMRY_LENGTH"] + "&SM_URL=" + url ) @@ -97,7 +96,7 @@ def summarize_url(self, url): else: return response.json() - @bridge.bridge_command(description="Summarizes a given URL using the SMMRY API.") + @commands.slash_command(description="Summarizes a given URL using the SMMRY API.") async def summarize(self, ctx, *, url): """ Summarizes a given URL using the SMMRY API. diff --git a/notify.py b/notify.py index 87a46c7..ef7e90c 100644 --- a/notify.py +++ b/notify.py @@ -1,22 +1,37 @@ +import os +import sys from datetime import datetime -import apprise - -from configload import config +try: + import apprise +except ImportError: + apprise = None class Notifier: - def __init__(self, apprise_endpoints: list[str]): - self.apobj = apprise.Apprise() - if apprise_endpoints: + + def __init__(self, apprise_endpoints: list[str] = None): + self.apobj = None + if apprise and apprise_endpoints: + self.apobj = apprise.Apprise() [self.apobj.add(x) for x in apprise_endpoints] def notify(self, message: str): - return self.apobj.notify( - body=f"{message}" - f'Datetime: {datetime.now().strftime("%m/%d/%Y %H:%M:%S")}', - title="Dolores", - ) + timestamp = datetime.now().strftime("%m/%d/%Y %H:%M:%S") + full_message = f"{message}\nDatetime: {timestamp}" + + if self.apobj: + return self.apobj.notify( + body=full_message, + title="Dolores", + ) + else: + print(full_message, file=sys.stderr) + return False -notif = Notifier(config["DISCORD"]["apprise_endpoints"]) +# Initialize Notifier with APPRISE_ENDPOINTS if available +apprise_endpoints = os.environ.get("APPRISE_ENDPOINTS") +if apprise_endpoints: + apprise_endpoints = apprise_endpoints.split(",") +notif = Notifier(apprise_endpoints) diff --git a/requirements.txt b/requirements.txt index 8e0e93e..3cd7424 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ requests==2.32.3 pyyaml==6.0.1 -py-cord[voice]==2.5.0 -openai==1.30.5 +py-cord[voice]==2.6.0 +openai==1.37.0 apprise==1.8.0 pomice==2.9.0 From 8237191fb3eab8c0595744bfd0006671a45f672d Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Thu, 25 Jul 2024 11:20:15 -0500 Subject: [PATCH 09/15] Finished separating config from hard-coded strings --- README.md | 13 +++++++- config/example_config.yml | 68 --------------------------------------- dolores.py | 32 +++++++++++------- locales/strings.json | 56 ++++++++++++++++++++++++++++++++ modules/audio.py | 4 ++- modules/rolling.py | 5 ++- modules/scheduling.py | 4 ++- modules/text.py | 21 ++++++------ requirements.txt | 1 + 9 files changed, 111 insertions(+), 93 deletions(-) delete mode 100644 config/example_config.yml create mode 100644 locales/strings.json diff --git a/README.md b/README.md index a23ed6c..b964016 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,12 @@ Dolores can be run easily directly as a python program. Generally, I have her ru ## Config -Dolores runs using a number of environment variables for API keys and settings. Not all are required for core functionality. Each cog has a corresponding environment variable to turn it on or off. Will determine whether the cog is loaded when Dolores is run. If a cog isn't loaded, none of the environment variables that are associated with it are required. +Dolores runs using a number of environment variables for API keys and settings. Not all are required for core functionality. Each cog has a corresponding environment variable to turn it on or off. Will determine whether the cog is loaded when Dolores is run. If a cog isn't loaded, none of the environment variables that are associated with that module are required. The only explicitly required environment variable is `DISCORD_API_KEY`. +Env vars can be provided via a `.env` file in the main directory, if desired. Useful for testing locally. + | Required by Which Module | Env Var Name | Description | | --- | --- | --- | | | | | @@ -25,15 +27,24 @@ The only explicitly required environment variable is `DISCORD_API_KEY`. | Scheduling | NOTION_BASE_URL | Base URL of the Notion API, should be | | Scheduling | NOTION_DATABASE_ID | ID for database where stream info is kept | | None | TWITCH_CLIENT_ID | Not yet used | +| None | TWITCH_CLIENT_SECRET | Not yet used | | None | TWITCH_BASE_URL | Not yet used | | None | TWITCH_BROADCASTER_ID | Not yet used | +| None | TWITCH_BROADCASTER_NAME | not yet used | | Text | REPLY_METHOD | Method to use for generating a reply to user's message. At this point only 'openai' is supported. | | Text | OPENAI_API_KEY | API Key used for generating replies | +| Text | OPENAI_MODEL | Which LLM model to use. | | Text | MAX_TOKENS | Max number of tokens generated in LLM chat. | +| Text | TEMPERATURE | Float value for temperature of LLM chat response. | +| Text | TOP_P | Float value alternative to temperature with LLM chat. | +| Text | FREQUENCY_PENALTY | Frequency penalty for LLM chat. | +| Text | PRESENCE_PENALTY | Presence penalty for LLM chat. | | Text | SMMRY_BASE_URL | base URL for the SMMRY API. | | Text | SMMRY_API_KEY | API key for the SMMRY API | | Text | SMMRY_QUOTE_AVOID | SMMRY boolean option on whether to avoid or include quotes in text that's summarized. Usually true. | | Text | SMMRY_LENGTH | max number of sentences a summary should be. | +| Text | SMMRY_MIN_REDUCED_AMOUNT | Minium percentage a news article should be reduced by summarization to post it. | +| Text | NEWS_CHANNEL_ID | Not currently used, but was automatically summarizing articles posted into a particular discord channel. | ## Compose diff --git a/config/example_config.yml b/config/example_config.yml deleted file mode 100644 index 7530fcd..0000000 --- a/config/example_config.yml +++ /dev/null @@ -1,68 +0,0 @@ -# Contains all config options for Dolores -DISCORD: - bot_api_key: BOT_API_KEY - test_bot_api_key: TEST_BOT_API_KEY - sarcastic_names: - [ - "my lovely", - "darling", - "sweetie", - "sweetie-pie", - "my sugar lump princess", - "my big strong warrior", - "dearest", - "lover", - "honey", - "foxy mama", - "loathsome dung eater", - "baby girl", - "Felicia", - "mamacita", - "diva", - "hunty", - "Queen", - "Jan", - ] - reply_method: openai - news_channel_id: channel id you want Doloresto watch for summarization -YTDL: - format: bestaudio/best - outtmpl: "%(extractor)s-%(id)s-%(title)s.%(ext)s" - restrictfilenames: True - noplaylist: True - nocheckcertificate: True - ignoreerrors: False - logtostderr: False - quiet: True - no_warnings: True - default_search: auto - source_address: "0.0.0.0" -NOTION: - base_url: https://api.notion.com/v1/ - api_key: API_KEY - notion_version: "2022-06-28" - database_id: NOTION_DATABASE_ID_WITH_CALENDAR -TWITCH: - client_id: TWITCH_CLIENT_ID - client_secret: TWITCH_CLIENT_SECRET - broadcaster_name: BROADCASTER_NAME - broadcaster_id: BROADCASTER_ID -STABLEDIFFUSION: - api_key: API_KEY -OPENAI: - api_key: API_KEY - model: gpt-3.5-turbo - max_tokens: 60 - temperature: 0.9 - top_p: 1 - frequency_penalty: 0 - presence_penalty: 0.6 -SELFHOST: - ip_address: something -SMMRY: - api_key: API_KEY - base_url: https://api.smmry.com -LAVALINK: - host: 0.0.0.0 - port: 1234 - password: password diff --git a/dolores.py b/dolores.py index 432a892..469f742 100644 --- a/dolores.py +++ b/dolores.py @@ -9,13 +9,19 @@ """ import asyncio +import json import os import re import sys from datetime import datetime import discord -from discord.ext import bridge, commands +from discord.ext import commands +from dotenv import load_dotenv + +# Check if .env file present, if so load vars from it +if os.path.exists(".env"): + load_dotenv() from modules import * from notify import notif @@ -36,6 +42,9 @@ if os.environ["TEXT_ENABLED"].lower() == "true": bot.add_cog(text(bot)) +with open(os.path.join("locales", "strings.json"), "r") as f: + summary_exclude_strings = json.load(f).get("SUMMARY_EXCLUDED_STRINGS", []) + async def handle_mention(message): """ @@ -78,7 +87,7 @@ async def handle_news(message): if summary != "": if "sm_api_content_reduced" in summary: reduced_amount = summary["sm_api_content_reduced"].replace("%", "") - if int(reduced_amount) > config["SMMRY"]["min_reduced_amount"]: + if int(reduced_amount) > int(os.environ["SMMRY_MIN_REDUCED_AMOUNT"]): if "sm_api_title" in summary: embed_title = summary["sm_api_title"] else: @@ -144,16 +153,15 @@ async def on_message(message): await handle_mention(message) # Check for if message was posted in news channel and contains a non-media URL - if ( - message.channel.id == config["DISCORD"]["news_channel_id"] - and "https" in message.clean_content - and not any( - excluded in message.clean_content - for excluded in config["SMMRY"]["excluded_strings"] - ) - ): - # await handle_news(message) - pass + # if ( + # os.environ["TEXT_ENABLED"].lower() == "true" + # and message.channel.id == int(os.environ["NEWS_CHANNEL_ID"]) + # and "https" in message.clean_content + # and not any( + # excluded in message.clean_content for excluded in summary_exclude_strings + # ) + # ): + # await handle_news(message) # Normal command processing await bot.process_commands(message) diff --git a/locales/strings.json b/locales/strings.json new file mode 100644 index 0000000..6df0c99 --- /dev/null +++ b/locales/strings.json @@ -0,0 +1,56 @@ +{ + "LLM_SYSTEM_MESSAGES": [ + "Your name is Dolores.", + "Your replies should be snarky, sarcastic, and funny.", + "You are a female AI that runs on a Discord server.", + "You have inner thoughts and monologues.", + "You will occasionally say ominous things.", + "The name of your programmer is Jordan." + ], + "SARCASTIC_NAMES": [ + "my lovely", + "darling", + "sweetie", + "sweetie-pie", + "my sugar lump princess", + "my big strong warrior", + "dearest", + "lover", + "honey", + "foxy mama", + "loathsome dung eater", + "baby girl", + "Felicia", + "mamacita", + "diva", + "hunty", + "Queen", + "Jan" + ], + "SNARKY_COMMENTS": [ + "How many sessions is it gonna take before you people understand how to use my commands?", + "Wrong.", + "I cannot do that...", + "Nope.", + "Not a command, sweetie.", + "Must I hold your hand for this?", + "Oh, ya still cannot type?", + "Girl, go hit up Mavis Beacon, cuz you cannot type.", + "Close.", + "Slow.", + "Homeless.", + "Goon.", + "You goonga.", + "Prison, honey.", + "No.", + "Big Dumb." + ], + "SUMMARY_EXCLUDED_STRINGS": [ + "tenor", + "giphy", + "imgur", + "gfycat", + "youtube", + "youtu.be" + ] +} diff --git a/modules/audio.py b/modules/audio.py index 8376a8f..aec3de7 100644 --- a/modules/audio.py +++ b/modules/audio.py @@ -15,7 +15,9 @@ class Player(pomice.Player): - """Custom pomice Player class.""" + """ + Custom pomice Player class. + """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/modules/rolling.py b/modules/rolling.py index 01c2c28..eeca95c 100644 --- a/modules/rolling.py +++ b/modules/rolling.py @@ -4,11 +4,14 @@ rolling.py """ +import json +import os import random from discord.ext import commands -sarcastic_names = config["DISCORD"]["sarcastic_names"] +with open(os.path.join("locales", "strings.json"), "r") as f: + sarcastic_names = json.load(f).get("SARCASTIC_NAMES", []) class rolling(commands.Cog): diff --git a/modules/scheduling.py b/modules/scheduling.py index d713834..e1174f4 100644 --- a/modules/scheduling.py +++ b/modules/scheduling.py @@ -2,6 +2,7 @@ scheduling.py """ +import json import os import random import urllib.parse @@ -18,7 +19,8 @@ twitch_headers = {"Authorization": "", "Client-ID": os.environ["TWITCH_CLIENT_ID"]} -sarcastic_names = config["DISCORD"]["sarcastic_names"] +with open(os.path.join("locales", "strings.json"), "r") as f: + sarcastic_names = json.load(f).get("SARCASTIC_NAMES", []) class Decorators: diff --git a/modules/text.py b/modules/text.py index 8c21af0..caa8bf8 100644 --- a/modules/text.py +++ b/modules/text.py @@ -2,6 +2,7 @@ text.py module """ +import json import os import random import sys @@ -18,9 +19,11 @@ openai.api_key = os.environ["OPENAI_API_KEY"] message_history = deque(maxlen=10) -system_messages = [ - {"role": "system", "content": x} for x in config["DISCORD"]["system_messages"] -] + +with open(os.path.join("locales", "strings.json"), "r") as f: + json_data = json.load(f) + system_messages = json_data.get("LLM_SYSTEM_MESSAGES", []) + snarky_comments = json_data.get("SNARKY_COMMENTS", []) class text(commands.Cog): @@ -41,13 +44,13 @@ def generate_reply(self, message): # Generate a reply using the OpenAI API response = openai.chat.completions.create( - model=config["OPENAI"]["model"], + model=os.environ["OPENAI_MODEL"], messages=system_messages + list(message_history), max_tokens=int(os.environ["MAX_TOKENS"]), - temperature=config["OPENAI"]["temperature"], - top_p=config["OPENAI"]["top_p"], - frequency_penalty=config["OPENAI"]["frequency_penalty"], - presence_penalty=config["OPENAI"]["presence_penalty"], + temperature=float(os.environ["TEMPERATURE"]), + top_p=float(os.environ["TOP_P"]), + frequency_penalty=float(os.environ["FREQUENCY_PENALTY"]), + presence_penalty=float(os.environ["PRESENCE_PENALTY"]), ) reply = response.choices[0].message.content # Add the reply to the message history @@ -66,7 +69,7 @@ def generate_snarky_comment(self): """ Generates a snarky comment to be used when a user tries to use a command that does not exist. """ - return random.choice(config["DISCORD"]["snarky_comments"]) + return random.choice(snarky_comments) def summarize_url(self, url): """ diff --git a/requirements.txt b/requirements.txt index 3cd7424..1de3bf8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ py-cord[voice]==2.6.0 openai==1.37.0 apprise==1.8.0 pomice==2.9.0 +python-dotenv==1.0.1 From de37a44a428b65bb55a8096742e8eadcb99ee1ba Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Wed, 31 Jul 2024 16:04:19 -0500 Subject: [PATCH 10/15] Work on suppressing warnings --- dolores.py | 12 +++--------- modules/audio.py | 19 +++++++++++++++---- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/dolores.py b/dolores.py index 469f742..405f7e2 100644 --- a/dolores.py +++ b/dolores.py @@ -1,8 +1,4 @@ """ -dolores.py -Author: Jordan Maynor -Date: Apr 2020 - Dolores is a chatbot that connects to a Discord server. Her primary use is in being able to roll dice for players of a tabletop roleplaying game but she is also capable of doing some basic audio things. @@ -53,6 +49,7 @@ async def handle_mention(message): where the message was posted. """ ctx = await bot.get_context(message) + if os.environ["TEXT_ENABLED"].lower() == "true": text_instance = text(bot) clean_message = message.clean_content.replace("@Dolores", "Dolores") @@ -69,11 +66,6 @@ async def handle_mention(message): async def handle_news(message): """ handle_news handles bot's response when a news article is posted in the news channel - TODO: Currently not being called. Consider using some other service. - Too frequently summary isn't useful. Not necessarily SMMRY's fault. - It's because too much bullshit is on modern "news" sites that it's not able - to pull the actual article content. But maybe some other approach would be - better. Look into web scrapers. Firefox's reader mode comes to mind. """ ctx = await bot.get_context(message) # Try and extract URL from message @@ -111,6 +103,7 @@ async def on_ready(): on_ready gets called when the bot starts up or potentially when restarts in event of reconnection. It prints some basic info to the console. """ + assert bot.user is not None print("Time is: ", datetime.now()) print("Bring yourself online, ", bot.user.name) print("-----------------------------") @@ -145,6 +138,7 @@ async def on_message(message): # If someone mentions Dolores, she will respond to them, # unless she is the one who sent the message or it is an @everyone mention + assert bot.user is not None if ( bot.user.mentioned_in(message) and message.author.id != bot.user.id diff --git a/modules/audio.py b/modules/audio.py index aec3de7..c46543c 100644 --- a/modules/audio.py +++ b/modules/audio.py @@ -8,6 +8,7 @@ import math import os from contextlib import suppress +from typing import Optional import discord import pomice @@ -60,25 +61,29 @@ async def do_next(self) -> None: if track.is_stream: embed = discord.Embed( title="Now playing", - description=f":red_circle: **LIVE** [{track.title}]({track.uri}) [{track.requester.mention}]", + description=f":red_circle: **LIVE** [{track.title}]({track.uri})", ) self.controller = await self.context.send(embed=embed) else: embed = discord.Embed( title=f"Now playing", - description=f"[{track.title}]({track.uri}) [{track.requester.mention}]", + description=f"[{track.title}]({track.uri})", ) self.controller = await self.context.send(embed=embed) async def teardown(self): - """Clear internal states, remove player controller and disconnect.""" + """ + Clear internal states, remove player controller and disconnect. + """ with suppress((discord.HTTPException), (KeyError)): await self.destroy() if self.controller: await self.controller.delete() async def set_context(self, ctx: commands.Context): - """Set context for the player""" + """ + Set context for the player + """ self.context = ctx self.dj = ctx.author @@ -114,6 +119,7 @@ def required(self, ctx: commands.Context): channel = self.bot.get_channel(int(player.channel.id)) required = math.ceil((len(channel.members) - 1) / 2.5) + assert ctx.command is not None if ctx.command.name == "stop": if len(channel.members) == 3: required = 2 @@ -124,6 +130,7 @@ def is_privileged(self, ctx: commands.Context): """ Check whether the user is an Admin or DJ. """ + assert ctx.voice_client is not None player: Player = ctx.voice_client return player.dj == ctx.author or ctx.author.guild_permissions.kick_members @@ -160,6 +167,7 @@ async def join( "You must be in a voice channel to use this command " "without specifying the channel argument.", ) + assert ctx.author is None await ctx.author.voice.channel.connect(cls=Player) player: Player = ctx.voice_client @@ -209,6 +217,8 @@ async def play( if not results: await ctx.send("No results were found for that search term", delete_after=7) + assert results is not None + if isinstance(results, pomice.Playlist): for track in results.tracks: player.queue.put(track) @@ -369,6 +379,7 @@ async def stop(self, ctx: discord.commands.context.ApplicationContext): Stops the currently playing song, if one is playing. Ex: -stop """ + assert ctx.voice_client is not None if ctx.voice_client.is_playing(): ctx.voice_client.stop() await ctx.respond("Stopped playing.") From a292b9caa09a07bd06c7ea494473824d4793eec8 Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Wed, 7 Aug 2024 14:45:23 -0500 Subject: [PATCH 11/15] Move to logging package --- .github/dependabot.yml | 4 ++++ README.md | 2 +- dolores.py | 18 ++++++++---------- logger.py | 30 ++++++++++++++++++++++++++++++ modules/audio.py | 17 +++++++++++++---- modules/rolling.py | 8 ++++---- modules/scheduling.py | 33 ++++++++++----------------------- modules/text.py | 22 ++++++++++++---------- notify.py | 37 ------------------------------------- 9 files changed, 82 insertions(+), 89 deletions(-) create mode 100644 logger.py delete mode 100644 notify.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f1ae72a..45aba75 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,3 +13,7 @@ updates: directory: "/" schedule: interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/README.md b/README.md index b964016..cd910a9 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Env vars can be provided via a `.env` file in the main directory, if desired. Us | Base | AUDIO_ENABLED | Enables audio cog, when set as true | | Base | SCHEDULING_ENABLED | Enables scheduling cog, when set as true | | Base | TEXT_ENABLED | | -| None | APPRISE_ENDPOINTS | Optional comma-separated list of apprise endpoints to send error messages to. otherwise prints to stderr | +| Base | LOG_LEVEL | Level of logging. Dolores uses DEBUG, INFO, and ERROR. | | Scheduling | NOTION_API_KEY | API Key for querying data from Notion | | Scheduling | NOTION_VERSION | Version of Notion API used for querying. | | Scheduling | NOTION_BASE_URL | Base URL of the Notion API, should be | diff --git a/dolores.py b/dolores.py index 405f7e2..468f9bd 100644 --- a/dolores.py +++ b/dolores.py @@ -19,14 +19,14 @@ if os.path.exists(".env"): load_dotenv() +from logger import logger from modules import * -from notify import notif intents = discord.Intents.all() intents.members = True bot = commands.Bot(case_insensitive=True, intents=intents) -# Add main cog module +# Add main cog module, no 3rd party dependencies so no reason not to add bot.add_cog(rolling(bot)) # Add modules based on config @@ -79,7 +79,9 @@ async def handle_news(message): if summary != "": if "sm_api_content_reduced" in summary: reduced_amount = summary["sm_api_content_reduced"].replace("%", "") - if int(reduced_amount) > int(os.environ["SMMRY_MIN_REDUCED_AMOUNT"]): + if int(reduced_amount) > int( + os.environ.get("SMMRY_MIN_REDUCED_AMOUNT", 65) + ): if "sm_api_title" in summary: embed_title = summary["sm_api_title"] else: @@ -101,12 +103,10 @@ async def handle_news(message): async def on_ready(): """ on_ready gets called when the bot starts up or potentially when restarts - in event of reconnection. It prints some basic info to the console. + in event of a reconnection. """ assert bot.user is not None - print("Time is: ", datetime.now()) - print("Bring yourself online, ", bot.user.name) - print("-----------------------------") + logger.info("Dolores has connected to Discord.") @bot.event @@ -122,8 +122,7 @@ async def on_command_error(ctx, error): if isinstance(error, (commands.CommandNotFound)): await ctx.send(text_instance.generate_snarky_comment()) else: - notif.notify(f"Error: {error}") - print(error, file=sys.stderr) + logger.error(error) else: await ctx.send("An error occurred. Please try again.") @@ -165,5 +164,4 @@ async def on_message(message): """ Main program entry point """ - print("Starting main program...") bot.run(os.environ["DISCORD_API_KEY"]) diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..6984571 --- /dev/null +++ b/logger.py @@ -0,0 +1,30 @@ +""" +This module contains logger config for the project. +""" + +import logging +import os + +# Create a custom logger +logger = logging.getLogger("dolores") + +# Set the default log level +log_level = os.getenv("LOG_LEVEL", "ERROR").upper() +logger.setLevel(log_level) + +# Create handlers +console_handler = logging.StreamHandler() +file_handler = logging.FileHandler("dolores.log") + +# Set log level for handlers +console_handler.setLevel(log_level) +file_handler.setLevel(log_level) + +# Create formatters and add them to handlers +formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +console_handler.setFormatter(formatter) +file_handler.setFormatter(formatter) + +# Add handlers to the logger +logger.addHandler(console_handler) +logger.addHandler(file_handler) diff --git a/modules/audio.py b/modules/audio.py index c46543c..2a5c543 100644 --- a/modules/audio.py +++ b/modules/audio.py @@ -14,6 +14,8 @@ import pomice from discord.ext import commands +from logger import logger + class Player(pomice.Player): """ @@ -142,14 +144,16 @@ async def on_pomice_track_end(self, player: Player, track, _): @commands.Cog.listener() async def on_pomice_track_stuck(self, player: Player, track, _): + logger.error(f"Track stuck: {track.title}") await player.do_next() @commands.Cog.listener() async def on_pomice_track_exception(self, player: Player, track, _): + logger.error(f"Track exception: {track.title}") await player.do_next() @commands.slash_command( - description="Joins the voice channel of the user who called the command." + description="Joins voice channel of user who called the command." ) async def join( self, @@ -159,6 +163,7 @@ async def join( ) -> None: """ Joins the voice channel of the user who called the command. + Ex: /join """ if not channel: channel = getattr(ctx.author.voice, "channel", None) @@ -180,7 +185,7 @@ async def leave(self, ctx: discord.commands.context.ApplicationContext): """ Disconnects Dolores from voice chat channel, if she is connected. Also stops any currently playing music - Ex: -leave + Ex: /leave """ if not (player := ctx.voice_client): return await ctx.send( @@ -197,7 +202,7 @@ async def play( ) -> None: """ Plays audio from a given search term. - Ex: -play https://www.youtube.com/watch?v=O1OTWCd40bc + Ex: /play https://www.youtube.com/watch?v=O1OTWCd40bc Dolores will play Wicked Games by The Weeknd """ # Checks if the player is in the channel before we play anything @@ -233,6 +238,7 @@ async def play( async def pause(self, ctx: discord.commands.context.ApplicationContext): """ Pauses the currently playing audio + Ex: /pause """ if not (player := ctx.voice_client): return await ctx.send( @@ -266,6 +272,7 @@ async def pause(self, ctx: discord.commands.context.ApplicationContext): async def resume(self, ctx: discord.commands.context.ApplicationContext): """ Resumes the currently paused audio + Ex: /resume """ if not (player := ctx.voice_client): return await ctx.send( @@ -299,6 +306,7 @@ async def resume(self, ctx: discord.commands.context.ApplicationContext): async def skip(self, ctx: discord.commands.context.ApplicationContext): """ Skip the currently playing song. + Ex: /skip """ if not (player := ctx.voice_client): return await ctx.send( @@ -338,6 +346,7 @@ async def skip(self, ctx: discord.commands.context.ApplicationContext): async def shuffle(self, ctx: discord.commands.context.ApplicationContext): """ Shuffles the queue. + Ex: /shuffle """ if not (player := ctx.voice_client): return await ctx.send( @@ -377,7 +386,7 @@ async def shuffle(self, ctx: discord.commands.context.ApplicationContext): async def stop(self, ctx: discord.commands.context.ApplicationContext): """ Stops the currently playing song, if one is playing. - Ex: -stop + Ex: /stop """ assert ctx.voice_client is not None if ctx.voice_client.is_playing(): diff --git a/modules/rolling.py b/modules/rolling.py index eeca95c..1b781df 100644 --- a/modules/rolling.py +++ b/modules/rolling.py @@ -28,7 +28,7 @@ def __init__(self, bot): async def roll(self, ctx, *, dice_batches: str): """ Rolls a dice in NdN format. - Ex: -roll 5d10 3d8 2d4 + Ex: /roll 5d10 3d8 2d4 Dolores would roll 5 d10s, 3 d8s, 2 d4s and return the result of each. """ await ctx.defer() @@ -68,7 +68,7 @@ async def roll(self, ctx, *, dice_batches: str): async def sroll(self, ctx, *, dice_batches: str): """ Rolls a secret dice in NdN format. - Ex: -sroll 5d10 3d8 2d4 + Ex: /sroll 5d10 3d8 2d4 Dolores would roll 5 d10s, 3 d8s, 2 d4s and return the result of each. """ await ctx.defer(ephemeral=True) @@ -110,7 +110,7 @@ async def sroll(self, ctx, *, dice_batches: str): async def choose(self, ctx, *, choices: str): """ Chooses between multiple choices. - Ex: -choose "Kill the king" "Save the king" "Screw the King" + Ex: /choose "Kill the king" "Save the king" "Screw the King" Dolores would randomly choose one of the options you give her and return the result. """ await ctx.defer() @@ -137,7 +137,7 @@ async def d20(self, ctx): async def sd20(self, ctx): """ Rolls a single d20 - Ex: -sd20 + Ex: /sd20 Dolores rolls a single d20 and returns the result secretly. """ await ctx.defer(ephemeral=True) diff --git a/modules/scheduling.py b/modules/scheduling.py index e1174f4..9774da7 100644 --- a/modules/scheduling.py +++ b/modules/scheduling.py @@ -12,6 +12,8 @@ import requests from discord.ext import commands +from logger import logger + notion_headers = { "Authorization": "Bearer " + os.environ["NOTION_API_KEY"], "Notion-Version": os.environ["NOTION_VERSION"], @@ -32,9 +34,9 @@ def refresh_twitch_token(decorated): def wrapper(self, *args, **kwargs): if "TWITCH_TOKEN_EXPIRES_AT" not in os.environ: - print() + pass else: - print() + pass return decorated(self, *args, **kwargs) wrapper.__name__ = decorated.__name__ @@ -67,10 +69,7 @@ def get_notion_schedule(self, filter: dict, sorts: list): timeout=30, ) if response.status_code != 200: - try: - print(response.json()) - except: - print(response.content) + logger.error(response.json()) return "" else: return response.json() @@ -108,10 +107,7 @@ def get_twitch_schedule( ) if response.status_code != 200: - try: - print(response.json()) - except: - print(response.content) + logger.error(response.json()) return "" return response.json() @@ -140,10 +136,7 @@ def add_twitch_segment(self, start_time, is_recurring, category_id, title): ) if response.status_code != 200: - try: - print(response.json()) - except: - print(response.content) + logger.error(response.json()) return "" return response.json() @@ -165,10 +158,7 @@ def delete_twitch_segment(self, id): ) if response.status_code != 204: - try: - print(response.json()) - except: - print(response.content) + logger.error(response.json()) return "" return response.json() @@ -204,10 +194,7 @@ def search_twitch_categories(self, query): ) if response.status_code != 200: - try: - print(response.json()) - except: - print(response.content) + logger.error(response.json()) return "" return response.json() @@ -218,7 +205,7 @@ def search_twitch_categories(self, query): async def schedule(self, ctx): """ Returns any streams scheduled for the next week. - Ex: -schedule + Ex: /schedule Dolores will return an embed of stream dates, names, and people. """ await ctx.defer() diff --git a/modules/text.py b/modules/text.py index caa8bf8..d0c0347 100644 --- a/modules/text.py +++ b/modules/text.py @@ -13,6 +13,8 @@ import requests from discord.ext import commands +from logger import logger + reply_method = os.environ["REPLY_METHOD"] if reply_method == "openai": @@ -46,11 +48,11 @@ def generate_reply(self, message): response = openai.chat.completions.create( model=os.environ["OPENAI_MODEL"], messages=system_messages + list(message_history), - max_tokens=int(os.environ["MAX_TOKENS"]), - temperature=float(os.environ["TEMPERATURE"]), - top_p=float(os.environ["TOP_P"]), - frequency_penalty=float(os.environ["FREQUENCY_PENALTY"]), - presence_penalty=float(os.environ["PRESENCE_PENALTY"]), + max_tokens=int(os.environ.get("MAX_TOKENS", 150)), + temperature=float(os.environ.get("TEMPERATURE", 0.9)), + top_p=float(os.environ.get("TOP_P", 1.0)), + frequency_penalty=float(os.environ.get("FREQUENCY_PENALTY", 0.0)), + presence_penalty=float(os.environ.get("PRESENCE_PENALTY", 0.6)), ) reply = response.choices[0].message.content # Add the reply to the message history @@ -88,13 +90,13 @@ def summarize_url(self, url): ) if response.status_code != 200: - print(str(response.status_code), file=sys.stderr) + logger.error(response.json()) return "" elif "sm_api_error" in response.json(): - print("Got error: ", response.json()["sm_api_error"], file=sys.stderr) + logger.error(response.json()["sm_api_error"]) return "" elif "sm_api_message" in response.json(): - print("Got message: " + response.json()["sm_api_message"], file=sys.stderr) + logger.error(response.json()["sm_api_message"]) return "" else: return response.json() @@ -103,13 +105,13 @@ def summarize_url(self, url): async def summarize(self, ctx, *, url): """ Summarizes a given URL using the SMMRY API. - Ex: -summarize https://www.newsite.com/article + Ex: /summarize https://www.newsite.com/article Dolores would provide a brief summary of the article. """ await ctx.defer() # Sanitize URL first, get rid of any query parameters url = url.split("?")[0] - print("Summarizing URL: " + url) + logger.info("Summarizing URL: " + url) response = self.summarize_url(url) if response == "": await ctx.respond("Unable to summarize that URL.") diff --git a/notify.py b/notify.py deleted file mode 100644 index ef7e90c..0000000 --- a/notify.py +++ /dev/null @@ -1,37 +0,0 @@ -import os -import sys -from datetime import datetime - -try: - import apprise -except ImportError: - apprise = None - - -class Notifier: - - def __init__(self, apprise_endpoints: list[str] = None): - self.apobj = None - if apprise and apprise_endpoints: - self.apobj = apprise.Apprise() - [self.apobj.add(x) for x in apprise_endpoints] - - def notify(self, message: str): - timestamp = datetime.now().strftime("%m/%d/%Y %H:%M:%S") - full_message = f"{message}\nDatetime: {timestamp}" - - if self.apobj: - return self.apobj.notify( - body=full_message, - title="Dolores", - ) - else: - print(full_message, file=sys.stderr) - return False - - -# Initialize Notifier with APPRISE_ENDPOINTS if available -apprise_endpoints = os.environ.get("APPRISE_ENDPOINTS") -if apprise_endpoints: - apprise_endpoints = apprise_endpoints.split(",") -notif = Notifier(apprise_endpoints) From 094f31c36c14a97647fbd332aae58cac9d96a1dd Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Thu, 8 Aug 2024 14:12:22 -0500 Subject: [PATCH 12/15] Restructuring code into single src folder --- dockerfile | 2 +- dolores.py => src/dolores.py | 2 +- {modules => src/modules}/__init__.py | 0 {modules => src/modules}/audio.py | 2 +- logger.py => src/modules/logger.py | 0 {modules => src/modules}/rolling.py | 0 {modules => src/modules}/scheduling.py | 2 +- {modules => src/modules}/text.py | 19 +++++++++---------- tests/test.py | 1 + 9 files changed, 14 insertions(+), 14 deletions(-) rename dolores.py => src/dolores.py (99%) rename {modules => src/modules}/__init__.py (100%) rename {modules => src/modules}/audio.py (99%) rename logger.py => src/modules/logger.py (100%) rename {modules => src/modules}/rolling.py (100%) rename {modules => src/modules}/scheduling.py (99%) rename {modules => src/modules}/text.py (90%) create mode 100644 tests/test.py diff --git a/dockerfile b/dockerfile index 6129f01..05f14fa 100644 --- a/dockerfile +++ b/dockerfile @@ -5,4 +5,4 @@ COPY . /home/dolores RUN python -m venv /home/dolores/.venv RUN . /home/dolores/.venv/bin/activate RUN pip install -r /home/dolores/requirements.txt -CMD python /home/dolores/dolores.py +CMD python /home/dolores/src/dolores.py diff --git a/dolores.py b/src/dolores.py similarity index 99% rename from dolores.py rename to src/dolores.py index 468f9bd..0768151 100644 --- a/dolores.py +++ b/src/dolores.py @@ -19,8 +19,8 @@ if os.path.exists(".env"): load_dotenv() -from logger import logger from modules import * +from src.modules.logger import logger intents = discord.Intents.all() intents.members = True diff --git a/modules/__init__.py b/src/modules/__init__.py similarity index 100% rename from modules/__init__.py rename to src/modules/__init__.py diff --git a/modules/audio.py b/src/modules/audio.py similarity index 99% rename from modules/audio.py rename to src/modules/audio.py index 2a5c543..49f6d44 100644 --- a/modules/audio.py +++ b/src/modules/audio.py @@ -14,7 +14,7 @@ import pomice from discord.ext import commands -from logger import logger +from src.modules.logger import logger class Player(pomice.Player): diff --git a/logger.py b/src/modules/logger.py similarity index 100% rename from logger.py rename to src/modules/logger.py diff --git a/modules/rolling.py b/src/modules/rolling.py similarity index 100% rename from modules/rolling.py rename to src/modules/rolling.py diff --git a/modules/scheduling.py b/src/modules/scheduling.py similarity index 99% rename from modules/scheduling.py rename to src/modules/scheduling.py index 9774da7..361df52 100644 --- a/modules/scheduling.py +++ b/src/modules/scheduling.py @@ -12,7 +12,7 @@ import requests from discord.ext import commands -from logger import logger +from src.modules.logger import logger notion_headers = { "Authorization": "Bearer " + os.environ["NOTION_API_KEY"], diff --git a/modules/text.py b/src/modules/text.py similarity index 90% rename from modules/text.py rename to src/modules/text.py index d0c0347..8765edf 100644 --- a/modules/text.py +++ b/src/modules/text.py @@ -13,7 +13,7 @@ import requests from discord.ext import commands -from logger import logger +from src.modules.logger import logger reply_method = os.environ["REPLY_METHOD"] @@ -77,16 +77,15 @@ def summarize_url(self, url): """ Summarizes a given URL using the SMMRY API. """ + params = { + "SM_API_KEY": os.environ["SMMRY_API_KEY"], + "SM_QUOTE_AVOID": os.environ.get("SMMRY_QUOTE_AVOID", "true").lower(), + "SM_LENGTH": os.environ["SMMRY_LENGTH"], + "SM_URL": url, + } response = requests.post( - os.environ["SMMRY_BASE_URL"] - + "?SM_API_KEY=" - + os.environ["SMMRY_API_KEY"] - + "&SM_QUOTE_AVOID=" - + os.environ["SMMRY_QUOTE_AVOID"].lower() - + "&SM_LENGTH=" - + os.environ["SMMRY_LENGTH"] - + "&SM_URL=" - + url + os.environ.get("SMMRY_BASE_URL", "https://api.smmry.com"), + params=params, ) if response.status_code != 200: diff --git a/tests/test.py b/tests/test.py new file mode 100644 index 0000000..2ae2839 --- /dev/null +++ b/tests/test.py @@ -0,0 +1 @@ +pass From ecd1244c89b7e2ed9a3fdfdaf3c0cf5331f60d6e Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Fri, 16 Aug 2024 16:07:21 -0500 Subject: [PATCH 13/15] Renamed text module to generation More accurately represents what all was going on in it. Also added image gen because why not. Started building out docstrings throughout program more, adding params. --- README.md | 33 +++++++++--------- src/dolores.py | 15 ++++----- src/modules/__init__.py | 2 +- src/modules/audio.py | 6 ++-- src/modules/{text.py => generation.py} | 46 ++++++++++++++++++++++++-- src/modules/rolling.py | 11 ++++-- src/modules/scheduling.py | 6 +++- 7 files changed, 84 insertions(+), 35 deletions(-) rename src/modules/{text.py => generation.py} (71%) diff --git a/README.md b/README.md index cd910a9..de7f4f3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Env vars can be provided via a `.env` file in the main directory, if desired. Us | Base | DISCORD_API_KEY | The main API key for the bot. | | Base | AUDIO_ENABLED | Enables audio cog, when set as true | | Base | SCHEDULING_ENABLED | Enables scheduling cog, when set as true | -| Base | TEXT_ENABLED | | +| Base | GENERATION_ENABLED | Enables URL sumamrization, LLM replies, image generation. | | Base | LOG_LEVEL | Level of logging. Dolores uses DEBUG, INFO, and ERROR. | | Scheduling | NOTION_API_KEY | API Key for querying data from Notion | | Scheduling | NOTION_VERSION | Version of Notion API used for querying. | @@ -31,20 +31,21 @@ Env vars can be provided via a `.env` file in the main directory, if desired. Us | None | TWITCH_BASE_URL | Not yet used | | None | TWITCH_BROADCASTER_ID | Not yet used | | None | TWITCH_BROADCASTER_NAME | not yet used | -| Text | REPLY_METHOD | Method to use for generating a reply to user's message. At this point only 'openai' is supported. | -| Text | OPENAI_API_KEY | API Key used for generating replies | -| Text | OPENAI_MODEL | Which LLM model to use. | -| Text | MAX_TOKENS | Max number of tokens generated in LLM chat. | -| Text | TEMPERATURE | Float value for temperature of LLM chat response. | -| Text | TOP_P | Float value alternative to temperature with LLM chat. | -| Text | FREQUENCY_PENALTY | Frequency penalty for LLM chat. | -| Text | PRESENCE_PENALTY | Presence penalty for LLM chat. | -| Text | SMMRY_BASE_URL | base URL for the SMMRY API. | -| Text | SMMRY_API_KEY | API key for the SMMRY API | -| Text | SMMRY_QUOTE_AVOID | SMMRY boolean option on whether to avoid or include quotes in text that's summarized. Usually true. | -| Text | SMMRY_LENGTH | max number of sentences a summary should be. | -| Text | SMMRY_MIN_REDUCED_AMOUNT | Minium percentage a news article should be reduced by summarization to post it. | -| Text | NEWS_CHANNEL_ID | Not currently used, but was automatically summarizing articles posted into a particular discord channel. | +| Generation | REPLY_METHOD | Method to use for generating a reply to user's message. At this point only 'openai' is supported. | +| Generation | OPENAI_API_KEY | API Key used for generating replies | +| Generation | OPENAI_MODEL | Which LLM model to use. | +| Generation | OPENAI_IMAGE_MODEL | Which image model to use. | +| Generation | MAX_TOKENS | Max number of tokens generated in LLM chat. | +| Generation | TEMPERATURE | Float value for temperature of LLM chat response. | +| Generation | TOP_P | Float value alternative to temperature with LLM chat. | +| Generation | FREQUENCY_PENALTY | Frequency penalty for LLM chat. | +| Generation | PRESENCE_PENALTY | Presence penalty for LLM chat. | +| Generation | SMMRY_BASE_URL | base URL for the SMMRY API. | +| Generation | SMMRY_API_KEY | API key for the SMMRY API | +| Generation | SMMRY_QUOTE_AVOID | SMMRY boolean option on whether to avoid or include quotes in text that's summarized. Usually true. | +| Generation | SMMRY_LENGTH | max number of sentences a summary should be. | +| Generation | SMMRY_MIN_REDUCED_AMOUNT | Minium percentage a news article should be reduced by summarization to post it. | +| Generation | NEWS_CHANNEL_ID | Not currently used, but was automatically summarizing articles posted into a particular discord channel. | ## Compose @@ -97,7 +98,7 @@ Dolores' functionality is divided into several cogs modules. `dolores.py` handle | Rolling | Used to roll dice and for any other randomization-based tasks. | | Audio | The audio module uses pomice/lavalink to stream audio. Uses a queue system. Largely a copy of the example bot given in pomice's documentation. | | Scheduling | The scheduling module is used for tasks related to Notion and Twitch. Pulling schedule in from a Notion database and posting to twitch schedule. | -| Text | Module for text generation. Currently using chatGPT, but intent is to move to add ability to use self-hosted LLM. Should be able to choose between something the user is hosting or commercially available alternatives. Also handles simple randomized snarky replies. | +| Generation | Module for text and image generation. Currently using chatGPT, but intent is to move to add ability to use self-hosted LLM. Should be able to choose between something the user is hosting or commercially available alternatives. Also handles simple randomized snarky replies. | ## Licensing diff --git a/src/dolores.py b/src/dolores.py index 0768151..972d962 100644 --- a/src/dolores.py +++ b/src/dolores.py @@ -8,7 +8,6 @@ import json import os import re -import sys from datetime import datetime import discord @@ -35,8 +34,8 @@ bot.add_cog(audio(bot)) if os.environ["SCHEDULING_ENABLED"].lower() == "true": bot.add_cog(scheduling(bot)) -if os.environ["TEXT_ENABLED"].lower() == "true": - bot.add_cog(text(bot)) +if os.environ["GENERATION_ENABLED"].lower() == "true": + bot.add_cog(generation(bot)) with open(os.path.join("locales", "strings.json"), "r") as f: summary_exclude_strings = json.load(f).get("SUMMARY_EXCLUDED_STRINGS", []) @@ -50,8 +49,8 @@ async def handle_mention(message): """ ctx = await bot.get_context(message) - if os.environ["TEXT_ENABLED"].lower() == "true": - text_instance = text(bot) + if os.environ["GENERATION_ENABLED"].lower() == "true": + text_instance = generation(bot) clean_message = message.clean_content.replace("@Dolores", "Dolores") clean_message = clean_message.replace("@everyone", "everyone") clean_message = clean_message.replace("@Testie", "Testie") @@ -71,7 +70,7 @@ async def handle_news(message): # Try and extract URL from message url = re.search(r"(https?://[^\s]+)", message.clean_content) if url is not None: - text_instance = text(bot) + text_instance = generation(bot) # If URL is found, get a summary of the article summary = text_instance.summarize_url(url.group(0).split("?")[0]) @@ -117,8 +116,8 @@ async def on_command_error(ctx, error): comeback. Any other error performs default behavior of logging to syserr. """ await ctx.defer() - if os.environ["TEXT_ENABLED"].lower() == "true": - text_instance = text(bot) + if os.environ["GENERATION_ENABLED"].lower() == "true": + text_instance = generation(bot) if isinstance(error, (commands.CommandNotFound)): await ctx.send(text_instance.generate_snarky_comment()) else: diff --git a/src/modules/__init__.py b/src/modules/__init__.py index 3f3ca83..9849da3 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -1,4 +1,4 @@ from .audio import * from .rolling import * from .scheduling import * -from .text import * +from .generation import * diff --git a/src/modules/audio.py b/src/modules/audio.py index 49f6d44..529c702 100644 --- a/src/modules/audio.py +++ b/src/modules/audio.py @@ -1,7 +1,7 @@ """ -audio.py module - -Much has come from the pomice example bot +Module contains code that deals with playing audio in voice channels. +Much has come directly from the pomice example bot so most of module relies on +having a lavalink server running. """ import asyncio diff --git a/src/modules/text.py b/src/modules/generation.py similarity index 71% rename from src/modules/text.py rename to src/modules/generation.py index 8765edf..aea5a7c 100644 --- a/src/modules/text.py +++ b/src/modules/generation.py @@ -1,5 +1,6 @@ """ -text.py module +Fancy schmancy "AI" nonsense. +Module contains code that deals with text processing and image generation. """ import json @@ -28,9 +29,9 @@ snarky_comments = json_data.get("SNARKY_COMMENTS", []) -class text(commands.Cog): +class generation(commands.Cog): """ - Commands for generating dialogue. + Commands for generating dialogue, summarization. """ def __init__(self, bot): @@ -106,6 +107,8 @@ async def summarize(self, ctx, *, url): Summarizes a given URL using the SMMRY API. Ex: /summarize https://www.newsite.com/article Dolores would provide a brief summary of the article. + + :param url: A URL to summarize. """ await ctx.defer() # Sanitize URL first, get rid of any query parameters @@ -122,3 +125,40 @@ async def summarize(self, ctx, *, url): embed = discord.Embed(title=embed_title) embed.add_field(name="Article Summary", value=response["sm_api_content"]) await ctx.respond(embed=embed) + + @commands.slash_command(description="Generates an image based on a given prompt.") + async def generate_image(self, ctx, *, prompt: str): + """ + Generates an image based on a given prompt and posts as a reply. + Ex: /generate_image A cat sitting on a table + Dolores would generate an image of a cat sitting on a table. + + :param prompt: A string prompt for generating an image. + """ + await ctx.defer() + if os.environ["OPENAI_API_KEY"] == "": + await ctx.respond("No OpenAI API key found.") + + try: + response = openai.images.generate( + prompt=prompt, + model=os.environ["OPENAI_IMAGE_MODEL"], + style=os.environ.get("IMAGE_STYLE", "natural"), + n=1, + response_format="url", + size="1792x1024", + user=str(ctx.author.id), + ) + image_url = response.data[0].url + except Exception as e: + logger.error(e) + await ctx.respond("Error generating image.") + + try: + embed = discord.Embed() + embed.description = prompt + embed.set_image(url=image_url) + await ctx.respond(embed=embed) + except Exception as e: + logger.error(e) + await ctx.respond("Error posting image to Discord.") diff --git a/src/modules/rolling.py b/src/modules/rolling.py index 1b781df..d9e7411 100644 --- a/src/modules/rolling.py +++ b/src/modules/rolling.py @@ -1,7 +1,6 @@ """ -The basic module of functionality. Rolls some dice. - -rolling.py +The basic module of functionality. Provides some simple randomization +functions for rolling dice. """ import json @@ -30,6 +29,8 @@ async def roll(self, ctx, *, dice_batches: str): Rolls a dice in NdN format. Ex: /roll 5d10 3d8 2d4 Dolores would roll 5 d10s, 3 d8s, 2 d4s and return the result of each. + + :param dice_batches: A string of dice rolls in NdN format. """ await ctx.defer() final_formatted_rolls = [] @@ -70,6 +71,8 @@ async def sroll(self, ctx, *, dice_batches: str): Rolls a secret dice in NdN format. Ex: /sroll 5d10 3d8 2d4 Dolores would roll 5 d10s, 3 d8s, 2 d4s and return the result of each. + + :param dice_batches: A string of dice rolls in NdN format. """ await ctx.defer(ephemeral=True) final_formatted_rolls = [] @@ -112,6 +115,8 @@ async def choose(self, ctx, *, choices: str): Chooses between multiple choices. Ex: /choose "Kill the king" "Save the king" "Screw the King" Dolores would randomly choose one of the options you give her and return the result. + + :param choices: A string of choices separated by spaces. """ await ctx.defer() await ctx.respond(random.choice(choices.split())) diff --git a/src/modules/scheduling.py b/src/modules/scheduling.py index 361df52..54a0223 100644 --- a/src/modules/scheduling.py +++ b/src/modules/scheduling.py @@ -1,5 +1,9 @@ """ -scheduling.py +This module contains functionality relating to getting schedule info +from Notion. + +Is intended to also have functionality for syncing schedule info +between Notion and Twitch. Not yet implemented. """ import json From 7055ecd9c3febe211d112537d55877a65d35303f Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Fri, 16 Aug 2024 16:23:59 -0500 Subject: [PATCH 14/15] Added IMAGE_STYLE env var --- README.md | 1 + src/modules/generation.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index de7f4f3..4f8fc3a 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Env vars can be provided via a `.env` file in the main directory, if desired. Us | Generation | OPENAI_API_KEY | API Key used for generating replies | | Generation | OPENAI_MODEL | Which LLM model to use. | | Generation | OPENAI_IMAGE_MODEL | Which image model to use. | +| Generation | IMAGE_STYLE | vivid or natural | | Generation | MAX_TOKENS | Max number of tokens generated in LLM chat. | | Generation | TEMPERATURE | Float value for temperature of LLM chat response. | | Generation | TOP_P | Float value alternative to temperature with LLM chat. | diff --git a/src/modules/generation.py b/src/modules/generation.py index aea5a7c..f2f88ba 100644 --- a/src/modules/generation.py +++ b/src/modules/generation.py @@ -140,10 +140,13 @@ async def generate_image(self, ctx, *, prompt: str): await ctx.respond("No OpenAI API key found.") try: + style = os.environ.get("IMAGE_STYLE", None) + if style not in ["natural", "vivid"]: + style = "natural" response = openai.images.generate( prompt=prompt, model=os.environ["OPENAI_IMAGE_MODEL"], - style=os.environ.get("IMAGE_STYLE", "natural"), + style=style, n=1, response_format="url", size="1792x1024", From 1efcb47e271cff5528021143e0a9d7cc0df74dd5 Mon Sep 17 00:00:00 2001 From: Jordan Maynor Date: Tue, 20 Aug 2024 19:36:29 -0500 Subject: [PATCH 15/15] Fixed imports, send changed to respond --- src/dolores.py | 6 +-- src/modules/audio.py | 90 +++++++++++++++++++++++++++------------ src/modules/generation.py | 4 +- src/modules/rolling.py | 2 +- src/modules/scheduling.py | 4 +- 5 files changed, 71 insertions(+), 35 deletions(-) diff --git a/src/dolores.py b/src/dolores.py index 972d962..927031c 100644 --- a/src/dolores.py +++ b/src/dolores.py @@ -15,11 +15,11 @@ from dotenv import load_dotenv # Check if .env file present, if so load vars from it -if os.path.exists(".env"): +if os.path.exists(os.path.join("..", ".env")): load_dotenv() from modules import * -from src.modules.logger import logger +from modules.logger import logger intents = discord.Intents.all() intents.members = True @@ -37,7 +37,7 @@ if os.environ["GENERATION_ENABLED"].lower() == "true": bot.add_cog(generation(bot)) -with open(os.path.join("locales", "strings.json"), "r") as f: +with open(os.path.join("..", "locales", "strings.json"), "r") as f: summary_exclude_strings = json.load(f).get("SUMMARY_EXCLUDED_STRINGS", []) diff --git a/src/modules/audio.py b/src/modules/audio.py index 529c702..60cb72f 100644 --- a/src/modules/audio.py +++ b/src/modules/audio.py @@ -14,7 +14,7 @@ import pomice from discord.ext import commands -from src.modules.logger import logger +from modules.logger import logger class Player(pomice.Player): @@ -178,7 +178,7 @@ async def join( # Set the player context so we can use it so send messages await player.set_context(ctx=ctx) - await ctx.send(f"Joined the voice channel `{channel}`") + await ctx.respond(f"Joined the voice channel `{channel}`") @commands.slash_command(description="Disconnects Dolores from voice channel.") async def leave(self, ctx: discord.commands.context.ApplicationContext): @@ -188,13 +188,13 @@ async def leave(self, ctx: discord.commands.context.ApplicationContext): Ex: /leave """ if not (player := ctx.voice_client): - return await ctx.send( + return await ctx.respond( "You must have the bot in a channel in order to use this command", delete_after=7, ) await player.destroy() - await ctx.send("Dolores has left the building.") + await ctx.respond("Dolores has left the building.") @commands.slash_command(description="Play audio stream in user's voice channel.") async def play( @@ -205,6 +205,7 @@ async def play( Ex: /play https://www.youtube.com/watch?v=O1OTWCd40bc Dolores will play Wicked Games by The Weeknd """ + await ctx.defer() # Checks if the player is in the channel before we play anything if not (player := ctx.voice_client): await ctx.author.voice.channel.connect(cls=Player) @@ -220,16 +221,20 @@ async def play( results = await player.get_tracks(search, ctx=ctx) if not results: - await ctx.send("No results were found for that search term", delete_after=7) + await ctx.respond( + "No results were found for that search term", delete_after=7 + ) assert results is not None if isinstance(results, pomice.Playlist): for track in results.tracks: player.queue.put(track) + await ctx.respond(f"Added {track.title} to the queue.") else: track = results[0] player.queue.put(track) + await ctx.respond(f"Added {track.title} to the queue.") if not player.is_playing: await player.do_next() @@ -240,8 +245,9 @@ async def pause(self, ctx: discord.commands.context.ApplicationContext): Pauses the currently playing audio Ex: /pause """ + await ctx.defer() if not (player := ctx.voice_client): - return await ctx.send( + return await ctx.respond( "You must have the bot in a channel in order to use this command", delete_after=7, ) @@ -250,7 +256,7 @@ async def pause(self, ctx: discord.commands.context.ApplicationContext): return if self.is_privileged(ctx): - await ctx.send("An admin or DJ has paused the player.", delete_after=10) + await ctx.respond("An admin or DJ has paused the player.", delete_after=10) player.pause_votes.clear() return await player.set_pause(True) @@ -259,11 +265,11 @@ async def pause(self, ctx: discord.commands.context.ApplicationContext): player.pause_votes.add(ctx.author) if len(player.pause_votes) >= required: - await ctx.send("Vote to pause passed. Pausing player.", delete_after=10) + await ctx.respond("Vote to pause passed. Pausing player.", delete_after=10) player.pause_votes.clear() await player.set_pause(True) else: - await ctx.send( + await ctx.respond( f"{ctx.author.mention} has voted to pause the player. Votes: {len(player.pause_votes)}/{required}", delete_after=15, ) @@ -274,8 +280,9 @@ async def resume(self, ctx: discord.commands.context.ApplicationContext): Resumes the currently paused audio Ex: /resume """ + await ctx.defer() if not (player := ctx.voice_client): - return await ctx.send( + return await ctx.respond( "You must have the bot in a channel in order to use this command", delete_after=7, ) @@ -284,7 +291,7 @@ async def resume(self, ctx: discord.commands.context.ApplicationContext): return if self.is_privileged(ctx): - await ctx.send("An admin or DJ has resumed the player.", delete_after=10) + await ctx.respond("An admin or DJ has resumed the player.", delete_after=10) player.resume_votes.clear() return await player.set_pause(False) @@ -293,11 +300,13 @@ async def resume(self, ctx: discord.commands.context.ApplicationContext): player.resume_votes.add(ctx.author) if len(player.resume_votes) >= required: - await ctx.send("Vote to resume passed. Resuming player.", delete_after=10) + await ctx.respond( + "Vote to resume passed. Resuming player.", delete_after=10 + ) player.resume_votes.clear() await player.set_pause(False) else: - await ctx.send( + await ctx.respond( f"{ctx.author.mention} has voted to resume the player. Votes: {len(player.resume_votes)}/{required}", delete_after=15, ) @@ -308,8 +317,9 @@ async def skip(self, ctx: discord.commands.context.ApplicationContext): Skip the currently playing song. Ex: /skip """ + await ctx.defer() if not (player := ctx.voice_client): - return await ctx.send( + return await ctx.respond( "You must have the bot in a channel in order to use this command", delete_after=7, ) @@ -318,13 +328,15 @@ async def skip(self, ctx: discord.commands.context.ApplicationContext): return if self.is_privileged(ctx): - await ctx.send("An admin or DJ has skipped the song.", delete_after=10) + await ctx.respond("An admin or DJ has skipped the song.", delete_after=10) player.skip_votes.clear() return await player.stop() if ctx.author == player.current.requester: - await ctx.send("The song requester has skipped the song.", delete_after=10) + await ctx.respond( + "The song requester has skipped the song.", delete_after=10 + ) player.skip_votes.clear() return await player.stop() @@ -333,11 +345,11 @@ async def skip(self, ctx: discord.commands.context.ApplicationContext): player.skip_votes.add(ctx.author) if len(player.skip_votes) >= required: - await ctx.send("Vote to skip passed. Skipping song.", delete_after=10) + await ctx.respond("Vote to skip passed. Skipping song.", delete_after=10) player.skip_votes.clear() await player.stop() else: - await ctx.send( + await ctx.respond( f"{ctx.author.mention} has voted to skip the song. Votes: {len(player.skip_votes)}/{required} ", delete_after=15, ) @@ -348,8 +360,9 @@ async def shuffle(self, ctx: discord.commands.context.ApplicationContext): Shuffles the queue. Ex: /shuffle """ + await ctx.defer() if not (player := ctx.voice_client): - return await ctx.send( + return await ctx.respond( "You must have the bot in a channel in order to use this command", delete_after=7, ) @@ -357,13 +370,13 @@ async def shuffle(self, ctx: discord.commands.context.ApplicationContext): return if player.queue.qsize() < 3: - return await ctx.send( + return await ctx.respond( "The queue is empty. Add some songs to shuffle the queue.", delete_after=15, ) if self.is_privileged(ctx): - await ctx.send("An admin or DJ has shuffled the queue.", delete_after=10) + await ctx.respond("An admin or DJ has shuffled the queue.", delete_after=10) player.shuffle_votes.clear() return player.queue.shuffle() @@ -371,13 +384,13 @@ async def shuffle(self, ctx: discord.commands.context.ApplicationContext): player.shuffle_votes.add(ctx.author) if len(player.shuffle_votes) >= required: - await ctx.send( + await ctx.respond( "Vote to shuffle passed. Shuffling the queue.", delete_after=10 ) player.shuffle_votes.clear() player.queue.shuffle() else: - await ctx.send( + await ctx.respond( f"{ctx.author.mention} has voted to shuffle the queue. Votes: {len(player.shuffle_votes)}/{required}", delete_after=15, ) @@ -388,7 +401,30 @@ async def stop(self, ctx: discord.commands.context.ApplicationContext): Stops the currently playing song, if one is playing. Ex: /stop """ - assert ctx.voice_client is not None - if ctx.voice_client.is_playing(): - ctx.voice_client.stop() - await ctx.respond("Stopped playing.") + await ctx.defer() + if not (player := ctx.voice_client): + return await ctx.respond( + "You must have the bot in a channel in order to use this command", + delete_after=7, + ) + + if not player.is_connected: + return + + if self.is_privileged(ctx): + await ctx.respond("An admin or DJ has stopped the player.", delete_after=10) + return await player.teardown() + + required = self.required(ctx) + player.stop_votes.add(ctx.author) + + if len(player.stop_votes) >= required: + await ctx.respond( + "Vote to stop passed. Stopping the player.", delete_after=10 + ) + await player.teardown() + else: + await ctx.respond( + f"{ctx.author.mention} has voted to stop the player. Votes: {len(player.stop_votes)}/{required}", + delete_after=15, + ) diff --git a/src/modules/generation.py b/src/modules/generation.py index f2f88ba..28a1d6b 100644 --- a/src/modules/generation.py +++ b/src/modules/generation.py @@ -14,7 +14,7 @@ import requests from discord.ext import commands -from src.modules.logger import logger +from modules.logger import logger reply_method = os.environ["REPLY_METHOD"] @@ -23,7 +23,7 @@ message_history = deque(maxlen=10) -with open(os.path.join("locales", "strings.json"), "r") as f: +with open(os.path.join("..", "locales", "strings.json"), "r") as f: json_data = json.load(f) system_messages = json_data.get("LLM_SYSTEM_MESSAGES", []) snarky_comments = json_data.get("SNARKY_COMMENTS", []) diff --git a/src/modules/rolling.py b/src/modules/rolling.py index d9e7411..7a7d512 100644 --- a/src/modules/rolling.py +++ b/src/modules/rolling.py @@ -9,7 +9,7 @@ from discord.ext import commands -with open(os.path.join("locales", "strings.json"), "r") as f: +with open(os.path.join("..", "locales", "strings.json"), "r") as f: sarcastic_names = json.load(f).get("SARCASTIC_NAMES", []) diff --git a/src/modules/scheduling.py b/src/modules/scheduling.py index 54a0223..f0ac637 100644 --- a/src/modules/scheduling.py +++ b/src/modules/scheduling.py @@ -16,7 +16,7 @@ import requests from discord.ext import commands -from src.modules.logger import logger +from modules.logger import logger notion_headers = { "Authorization": "Bearer " + os.environ["NOTION_API_KEY"], @@ -25,7 +25,7 @@ twitch_headers = {"Authorization": "", "Client-ID": os.environ["TWITCH_CLIENT_ID"]} -with open(os.path.join("locales", "strings.json"), "r") as f: +with open(os.path.join("..", "locales", "strings.json"), "r") as f: sarcastic_names = json.load(f).get("SARCASTIC_NAMES", [])