From 87488eaf99c328ec428bea1ba1336b4e04158296 Mon Sep 17 00:00:00 2001 From: Interitio Date: Sun, 15 Sep 2024 11:52:56 +1000 Subject: [PATCH 01/14] feat (timer): Add support for new timer layout. --- .gitmodules | 4 ++-- skins | 2 +- src/gui | 2 +- tests/__init__.py | 7 +++++++ tests/gui/__init__.py | 0 tests/gui/cards/goal_sample.py | 4 ++-- tests/gui/cards/pomo_sample.py | 15 +++++++++++++++ 7 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/gui/__init__.py create mode 100644 tests/gui/cards/pomo_sample.py diff --git a/.gitmodules b/.gitmodules index bd5ca8a8..f3a33f00 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,9 @@ [submodule "bot/gui"] path = src/gui - url = https://github.com/StudyLions/StudyLion-Plugin-GUI.git + url = git@github.com:Intery/CafeHelper-GUI.git [submodule "skins"] path = skins - url = https://github.com/Intery/pillow-skins.git + url = git@github.com:Intery/CafeHelper-Skins.git [submodule "src/modules/voicefix"] path = src/modules/voicefix url = https://github.com/Intery/StudyLion-voicefix.git diff --git a/skins b/skins index d3d6a28b..68685732 160000 --- a/skins +++ b/skins @@ -1 +1 @@ -Subproject commit d3d6a28bc9c573efe76780aedab1c87b4856f0c0 +Subproject commit 686857321ed16a45c38895417417b01ed9361f4b diff --git a/src/gui b/src/gui index c1bcb05c..40bc1403 160000 --- a/src/gui +++ b/src/gui @@ -1 +1 @@ -Subproject commit c1bcb05c25cd2ecec7dd726d55d30606b6b5c99b +Subproject commit 40bc14035593ee18d351b86e958d1882035b01ef diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..3d7a868a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +# !/bin/python3 + +import sys +import os + +sys.path.insert(0, os.path.join(os.getcwd())) +sys.path.insert(0, os.path.join(os.getcwd(), "src")) diff --git a/tests/gui/__init__.py b/tests/gui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/gui/cards/goal_sample.py b/tests/gui/cards/goal_sample.py index 7395e83f..39a4ad03 100644 --- a/tests/gui/cards/goal_sample.py +++ b/tests/gui/cards/goal_sample.py @@ -1,11 +1,11 @@ import asyncio import datetime as dt -from src.cards import WeeklyGoalCard +from gui.cards import WeeklyGoalCard async def get_card(): card = await WeeklyGoalCard.generate_sample() - with open('samples/weekly-sample.png', 'wb') as image_file: + with open('output/weekly-sample.png', 'wb') as image_file: image_file.write(card.fp.read()) if __name__ == '__main__': diff --git a/tests/gui/cards/pomo_sample.py b/tests/gui/cards/pomo_sample.py new file mode 100644 index 00000000..3658440d --- /dev/null +++ b/tests/gui/cards/pomo_sample.py @@ -0,0 +1,15 @@ +import asyncio +import datetime as dt +from gui.cards import BreakTimerCard, FocusTimerCard + + +async def get_card(): + card = await BreakTimerCard.generate_sample() + with open('output/break_timer_sample.png', 'wb') as image_file: + image_file.write(card.fp.read()) + card = await FocusTimerCard.generate_sample() + with open('output/focus_timer_sample.png', 'wb') as image_file: + image_file.write(card.fp.read()) + +if __name__ == '__main__': + asyncio.run(get_card()) From 66f7680482c3ac573c2127cafc92a8227c86cbec Mon Sep 17 00:00:00 2001 From: Interitio Date: Sun, 15 Sep 2024 12:24:40 +1000 Subject: [PATCH 02/14] feat (voice): Loosen now cmd restrictions. --- src/tracking/voice/cog.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/tracking/voice/cog.py b/src/tracking/voice/cog.py index 119693e5..95fd05cd 100644 --- a/src/tracking/voice/cog.py +++ b/src/tracking/voice/cog.py @@ -681,17 +681,14 @@ class VoiceTrackerCog(LionCog): ) @appcmds.guild_only async def now_cmd(self, ctx: LionContext, - tag: Optional[appcmds.Range[str, 0, 10]] = None, + tag: Optional[appcmds.Range[str, 0, 75]] = None, user: Optional[discord.Member] = None, clear: Optional[bool] = None ): if not ctx.guild: return - if not ctx.interaction: - return t = self.bot.translator.t - await ctx.interaction.response.defer(thinking=True, ephemeral=True) is_moderator = await moderator_ctxward(ctx) target = user if user is not None else ctx.author session = self.get_session(ctx.guild.id, target.id, create=False) @@ -715,7 +712,7 @@ class VoiceTrackerCog(LionCog): "{mention} has no running session!" )).format(mention=target.mention) ) - await ctx.interaction.edit_original_response(embed=error) + await ctx.reply(embed=error) return if clear: @@ -841,7 +838,7 @@ class VoiceTrackerCog(LionCog): description=desc, timestamp=utc_now() ) - await ctx.interaction.edit_original_response(embed=ack) + await ctx.reply(embed=ack) # ----- Configuration Commands ----- @LionCog.placeholder_group From 53366c0333abf4ddf23836a7ce2b791fe11f2735 Mon Sep 17 00:00:00 2001 From: Interitio Date: Sun, 15 Sep 2024 14:10:29 +1000 Subject: [PATCH 03/14] (voice): Adjust now responses. --- src/tracking/voice/cog.py | 94 +++++++-------------------------------- 1 file changed, 15 insertions(+), 79 deletions(-) diff --git a/src/tracking/voice/cog.py b/src/tracking/voice/cog.py index 95fd05cd..cb839021 100644 --- a/src/tracking/voice/cog.py +++ b/src/tracking/voice/cog.py @@ -5,7 +5,7 @@ import datetime as dt import discord from discord.ext import commands as cmds -from discord import app_commands as appcmds +from discord import AllowedMentions, app_commands as appcmds from data import Condition from meta import LionBot, LionCog, LionContext @@ -668,7 +668,7 @@ class VoiceTrackerCog(LionCog): @appcmds.describe( tag=_p( 'cmd:now|param:tag|desc', - "Describe what you are working on in 10 characters or less!" + "Describe what you are working!" ), user=_p( 'cmd:now|param:user|desc', @@ -681,7 +681,8 @@ class VoiceTrackerCog(LionCog): ) @appcmds.guild_only async def now_cmd(self, ctx: LionContext, - tag: Optional[appcmds.Range[str, 0, 75]] = None, + tag: Optional[str] = None, + *, user: Optional[discord.Member] = None, clear: Optional[bool] = None ): @@ -720,87 +721,27 @@ class VoiceTrackerCog(LionCog): if target == ctx.author: # Clear the author's tag await session.set_tag(None) - ack = discord.Embed( - colour=discord.Colour.brand_green(), - title=t(_p( - 'cmd:now|target:self|mode:clear|success|title', - "Session Tag Cleared" - )), - description=t(_p( - 'cmd:now|target:self|mode:clear|success|desc', - "Successfully unset your session tag." - )) - ) + ack = "Cleared your current task!" elif not is_moderator: # Trying to clear someone else's tag without being a moderator - ack = discord.Embed( - colour=discord.Colour.brand_red(), - title=t(_p( - 'cmd:now|target:other|mode:clear|error:perms|title', - "You can't do that!" - )), - description=t(_p( - 'cmd:now|target:other|mode:clear|error:perms|desc', - "You need to be a moderator to set or clear someone else's session tag." - )) - ) + ack = "You need to be a moderator to set or clear someone else's task!" else: # Clearing someone else's tag as a moderator await session.set_tag(None) - ack = discord.Embed( - colour=discord.Colour.brand_green(), - title=t(_p( - 'cmd:now|target:other|mode:clear|success|title', - "Session Tag Cleared!" - )), - description=t(_p( - 'cmd:now|target:other|mode:clear|success|desc', - "Cleared {target}'s session tag." - )).format(target=target.mention) - ) + ack = f"Cleared {target}'s current task!" elif tag: # Tag setting mode if target == ctx.author: # Set the author's tag await session.set_tag(tag) - ack = discord.Embed( - colour=discord.Colour.brand_green(), - title=t(_p( - 'cmd:now|target:self|mode:set|success|title', - "Session Tag Set!" - )), - description=t(_p( - 'cmd:now|target:self|mode:set|success|desc', - "You are now working on `{new_tag}`. Good luck!" - )).format(new_tag=tag) - ) + ack = f"Set your current task to `{tag}`, good luck! <:goodluck:1266447460146876497>" elif not is_moderator: # Trying the set someone else's tag without being a moderator - ack = discord.Embed( - colour=discord.Colour.brand_red(), - title=t(_p( - 'cmd:now|target:other|mode:set|error:perms|title', - "You can't do that!" - )), - description=t(_p( - 'cmd:now|target:other|mode:set|error:perms|desc', - "You need to be a moderator to set or clear someone else's session tag!" - )) - ) + ack = "You need to be a moderator to set or clear someone else's task!" else: # Setting someone else's tag as a moderator await session.set_tag(tag) - ack = discord.Embed( - colour=discord.Colour.brand_green(), - title=t(_p( - 'cmd:now|target:other|mode:set|success|title', - "Session Tag Set!" - )), - description=t(_p( - 'cmd:now|target:other|mode:set|success|desc', - "Set {target}'s session tag to `{new_tag}`." - )).format(target=target.mention, new_tag=tag) - ) + ack = f"Set {target}'s current task to `{tag}`" else: # Display tag and voice time if target == ctx.author: @@ -812,14 +753,14 @@ class VoiceTrackerCog(LionCog): else: desc = t(_p( 'cmd:now|target:self|mode:show_without_tag|desc', - "You have been working in {channel} since {time}!\n\n" + "You have been working in {channel} since {time}! " "Use `/now ` to set what you are working on." )) else: if session.tag: desc = t(_p( 'cmd:now|target:other|mode:show_with_tag|desc', - "{target} is current working in {channel}!\n" + "{target} is current working in {channel}! " "They have been working on **{tag}** since {time}." )) else: @@ -827,18 +768,13 @@ class VoiceTrackerCog(LionCog): 'cmd:now|target:other|mode:show_without_tag|desc', "{target} has been working in {channel} since {time}!" )) - desc = desc.format( + ack = desc.format( tag=session.tag, channel=f"<#{session.state.channelid}>", - time=discord.utils.format_dt(session.start_time, 't'), + time=discord.utils.format_dt(session.start_time, 'R'), target=target.mention, ) - ack = discord.Embed( - colour=discord.Colour.orange(), - description=desc, - timestamp=utc_now() - ) - await ctx.reply(embed=ack) + await ctx.reply(ack, allowed_mentions=AllowedMentions.none()) # ----- Configuration Commands ----- @LionCog.placeholder_group From f2c449d2e09fb8d5aa6acadb0ab68bd04f649f32 Mon Sep 17 00:00:00 2001 From: Interitio Date: Sun, 15 Sep 2024 15:15:54 +1000 Subject: [PATCH 04/14] feat (timer): Streamtimer channel editing --- src/modules/pomodoro/cog.py | 53 ++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/src/modules/pomodoro/cog.py b/src/modules/pomodoro/cog.py index 9d58ee99..cf26050c 100644 --- a/src/modules/pomodoro/cog.py +++ b/src/modules/pomodoro/cog.py @@ -47,16 +47,32 @@ class TimerChannel(Channel): super().__init__(**kwargs) self.cog = cog + self.channelid = 1261999440160624734 + self.goal = 12 + async def on_connection(self, websocket, event): await super().on_connection(websocket, event) - timer = self.cog.get_channel_timer(1261999440160624734) - if timer is not None: - await self.send_set( - timer.data.last_started, - timer.data.focus_length, - timer.data.break_length, - websocket=websocket, - ) + await self.send_set( + **await self.get_args_for(self.channelid), + goal=self.goal, + websocket=websocket, + ) + + async def send_updates(self): + await self.send_set( + **await self.get_args_for(self.channelid), + goal=self.goal, + ) + + async def get_args_for(self, channelid): + timer = self.cog.get_channel_timer(channelid) + if timer is None: + raise ValueError(f"Timer {channelid} doesn't exist.") + return { + 'start_at': timer.data.last_started, + 'focus_length': timer.data.focus_length, + 'break_length': timer.data.break_length, + } async def send_set(self, start_at, focus_length, break_length, goal=12, websocket=None): await self.send_event({ @@ -1059,8 +1075,18 @@ class TimerCog(LionCog): @low_management_ward async def streamtimer_update_cmd(self, ctx: LionContext, new_start: Optional[str] = None, - new_goal: int = 12): - timer = self.get_channel_timer(1261999440160624734) + new_goal: Optional[int] = None, + new_channel: Optional[discord.VoiceChannel] = None, + ): + if new_channel is not None: + channelid = self.channel.channelid = new_channel.id + else: + channelid = self.channel.channelid + + if new_goal is not None: + self.channel.goal = new_goal + + timer = self.get_channel_timer(channelid) if timer is None: return if new_start: @@ -1068,10 +1094,5 @@ class TimerCog(LionCog): start_at = await self.bot.get_cog('Reminders').parse_time_static(new_start, timezone) await timer.data.update(last_started=start_at) - await self.channel.send_set( - timer.data.last_started, - timer.data.focus_length, - timer.data.break_length, - goal=new_goal, - ) + await self.channel.send_updates() await ctx.reply("Stream Timer Updated") From 9c9107bf9d9f234f59463fde06032a5dc3762285 Mon Sep 17 00:00:00 2001 From: Interitio Date: Thu, 26 Sep 2024 01:46:39 +1000 Subject: [PATCH 05/14] fix(timers): Remove user from last_seen on leave. Fixes an issue where user inactivity was inaccurately tracked on rejoin. --- src/modules/pomodoro/cog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/modules/pomodoro/cog.py b/src/modules/pomodoro/cog.py index cf26050c..a7367b0c 100644 --- a/src/modules/pomodoro/cog.py +++ b/src/modules/pomodoro/cog.py @@ -320,8 +320,6 @@ class TimerCog(LionCog): return if member.bot: return - if 1148167212901859328 not in [role.id for role in member.roles]: - return # If a member is leaving or joining a running timer, trigger a status update if before.channel != after.channel: @@ -331,6 +329,7 @@ class TimerCog(LionCog): tasks = [] if leaving is not None: tasks.append(asyncio.create_task(leaving.update_status_card())) + leaving.last_seen.pop(member.id, None) if joining is not None: joining.last_seen[member.id] = utc_now() if not joining.running and joining.auto_restart: From 8f6fdf33814d7a70af69cacb503e1e76ce23ffdf Mon Sep 17 00:00:00 2001 From: Interitio Date: Fri, 27 Sep 2024 00:36:12 +1000 Subject: [PATCH 06/14] feat(counters): Add details and initial refactor. --- data/schema.sql | 2 + src/modules/counters/cog.py | 132 +++++++++++++++++------------ src/modules/counters/data.py | 9 +- src/modules/counters/migration.sql | 2 + 4 files changed, 86 insertions(+), 59 deletions(-) create mode 100644 src/modules/counters/migration.sql diff --git a/data/schema.sql b/data/schema.sql index 504ae859..51d5fb5f 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -1454,6 +1454,7 @@ CREATE TABLE shoutouts( CREATE TABLE counters( counterid SERIAL PRIMARY KEY, name TEXT NOT NULL, + category TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE UNIQUE INDEX counters_name ON counters (name); @@ -1464,6 +1465,7 @@ CREATE TABLE counter_log( userid INTEGER NOT NULL, value INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + details TEXT, context_str TEXT ); CREATE INDEX counter_log_counterid ON counter_log (counterid); diff --git a/src/modules/counters/cog.py b/src/modules/counters/cog.py index 9b1dd032..0e99aba9 100644 --- a/src/modules/counters/cog.py +++ b/src/modules/counters/cog.py @@ -25,6 +25,52 @@ class PERIOD(Enum): YEAR = ('this year', 'y', 'year', 'yearly') +def counter_cmd_factory( + counter: str, + response: str, + default_period: Optional[PERIOD] = PERIOD.STREAM, + context: Optional[str] = None +): + context = context or f"cmd: {counter}" + async def counter_cmd(cog, ctx: commands.Context, *, args: Optional[str] = None): + userid = int(ctx.author.id) + channelid = int((await ctx.channel.user()).id) + period, start_time = await cog.parse_period(userid, '', default=default_period) + + args = (args or '').strip(" 󠀀 ") + splits = args.split(maxsplit=1) + splits = [split.strip() for split in splits if split] + + details = None + amount = 1 + + if splits: + if splits[0].isdigit() or (splits[0].startswith('-') and splits[0][1:].isdigit()): + amount = int(splits[0]) + splits = splits[1:] + if splits: + details = ' '.join(splits) + + await cog.add_to_counter( + counter, userid, amount, + context=context, + details=details + ) + lb = await cog.leaderboard(counter, start_time=start_time) + user_total = lb.get(userid, 0) + total = sum(lb.values()) + await ctx.reply( + response.format( + total=total, + period=period, + period_name=period.value[0], + detailsorname=details or counter, + user_total=user_total, + ) + ) + return counter_cmd + + class CounterCog(LionCog): def __init__(self, bot: LionBot): self.bot = bot @@ -80,13 +126,19 @@ class CounterCog(LionCog): if row: await self.data.CounterEntry.table.delete_where(counterid=row.counterid) - async def add_to_counter(self, counter: str, userid: int, value: int, context: Optional[str]=None): + async def add_to_counter( + self, + counter: str, userid: int, value: int, + context: Optional[str]=None, + details: Optional[str]=None, + ): row = await self.fetch_counter(counter) return await self.data.CounterEntry.create( counterid=row.counterid, userid=userid, value=value, - context_str=context + context_str=context, + details=details ) async def leaderboard(self, counter: str, start_time=None): @@ -214,79 +266,47 @@ class CounterCog(LionCog): # Misc actual counter commands # TODO: Factor this out to a different module... - @commands.command() - async def tea(self, ctx: commands.Context, *, args: Optional[str]=None): - userid = int(ctx.author.id) - channelid = int((await ctx.channel.user()).id) - period, start_time = await self.parse_period(channelid, '') - counter = 'tea' + # TODO: Probably make these all alias commands - await self.add_to_counter( - counter, - userid, - 1, - context='cmd: tea' + tea_cmd = commands.command(name='tea')( + counter_cmd_factory( + 'tea', + "Enjoy your {detailsorname}! We have had {total} cups of tea {period_name}." ) - lb = await self.leaderboard(counter, start_time=start_time) - user_total = lb.get(userid, 0) - total = sum(lb.values()) - await ctx.reply(f"Enjoy your tea! We have had {total} cups of tea {period.value[0]}.") + ) @commands.command() async def tealb(self, ctx: commands.Context, *, args: str = ''): user = await ctx.channel.user() await ctx.reply(await self.formatted_lb('tea', args, int(user.id))) - @commands.command() - async def coffee(self, ctx: commands.Context, *, args: Optional[str]=None): - userid = int(ctx.author.id) - channelid = int((await ctx.channel.user()).id) - period, start_time = await self.parse_period(channelid, '') - counter = 'coffee' - - await self.add_to_counter( - counter, - userid, - 1, - context='cmd: coffee' + coffee_cmd = commands.command(name='coffee')( + counter_cmd_factory( + 'coffee', + "Enjoy your {detailsorname}! We have had {total} cups of coffee {period_name}." ) - lb = await self.leaderboard(counter, start_time=start_time) - user_total = lb.get(userid, 0) - total = sum(lb.values()) - await ctx.reply(f"Enjoy your coffee! We have had {total} cups of coffee {period.value[0]}.") + ) @commands.command() async def coffeelb(self, ctx: commands.Context, *, args: str = ''): user = await ctx.channel.user() await ctx.reply(await self.formatted_lb('coffee', args, int(user.id))) - @commands.command() - async def water(self, ctx: commands.Context, *, args: Optional[str]=None): - userid = int(ctx.author.id) - channelid = int((await ctx.channel.user()).id) - period, start_time = await self.parse_period(channelid, '') - counter = 'water' - - await self.add_to_counter( - counter, - userid, - 1, - context='cmd: water' + water_cmd = commands.command(name='water')( + counter_cmd_factory( + 'water', + "Good job hydrating! We have had {total} cups of tea {period_name}." ) - lb = await self.leaderboard(counter, start_time=start_time) - user_total = lb.get(userid, 0) - total = sum(lb.values()) - await ctx.reply(f"Good job hydrating! We have had {total} cups of water {period.value[0]}.") + ) @commands.command() async def waterlb(self, ctx: commands.Context, *, args: str = ''): user = await ctx.channel.user() await ctx.reply(await self.formatted_lb('water', args, int(user.id))) - @commands.command() - async def stuff(self, ctx: commands.Context, *, args: str = ''): - await ctx.reply(f"Stuff {args}") - - @cmds.hybrid_command('water') - async def d_water_cmd(self, ctx): - await ctx.reply(repr(ctx)) + stuff_cmd = commands.command(name='stuffcounter')( + counter_cmd_factory( + 'stuff', + "Good luck with {detailsorname}! We have done {total} stuffs {period_name}." + ) + ) diff --git a/src/modules/counters/data.py b/src/modules/counters/data.py index 8081c099..a7405053 100644 --- a/src/modules/counters/data.py +++ b/src/modules/counters/data.py @@ -10,7 +10,8 @@ class CounterData(Registry): CREATE TABLE counters( counterid SERIAL PRIMARY KEY, name TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + category TEXT ); CREATE UNIQUE INDEX counters_name ON counters (name); """ @@ -19,6 +20,7 @@ class CounterData(Registry): counterid = Integer(primary=True) name = String() + category = String() created_at = Timestamp() class CounterEntry(RowModel): @@ -31,7 +33,8 @@ class CounterData(Registry): userid INTEGER NOT NULL, value INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - context_str TEXT + context_str TEXT, + details TEXT ); CREATE INDEX counter_log_counterid ON counter_log (counterid); """ @@ -44,5 +47,5 @@ class CounterData(Registry): value = Integer() created_at = Timestamp() context_str = String() - + details = String() diff --git a/src/modules/counters/migration.sql b/src/modules/counters/migration.sql new file mode 100644 index 00000000..3a0220c0 --- /dev/null +++ b/src/modules/counters/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE counters ADD COLUMN category TEXT; +ALTER TABLE counter_log ADD COLUMN details TEXT; From e9946a981405fe9e9bd8517c34329a1ae6cdbdaf Mon Sep 17 00:00:00 2001 From: Interitio Date: Sat, 28 Sep 2024 15:38:13 +1000 Subject: [PATCH 07/14] feat(cog): Attach single twitchIO command to cog. --- src/meta/LionCog.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/meta/LionCog.py b/src/meta/LionCog.py index bc28f7c5..a2b1b625 100644 --- a/src/meta/LionCog.py +++ b/src/meta/LionCog.py @@ -47,6 +47,27 @@ class LionCog(Cog): return await super()._inject(bot, *args, *kwargs) + def add_twitch_command(self, bot: Bot, command: Command): + """ + Dynamically register a command with the given bot. + + The command will be deregistered on cog unload. + """ + # Remove any conflicting commands + if cmd := bot.get_command(command.name): + bot.remove_command(cmd.name) + self._twitch_cmds_.pop(command.name, None) + + try: + self._twitch_cmds_[command.name] = command + command._instance = self + command.cog = self + bot.add_command(command) + except Exception: + # Ensure the command doesn't die in the internal command cache + self._twitch_cmds_.pop(command.name, None) + raise + def _load_twitch_methods(self, bot: Bot): for name, command in self._twitch_cmds_.items(): command._instance = self From 28103655880bba6c5cd1fa453aa721cfc5fdf01f Mon Sep 17 00:00:00 2001 From: Interitio Date: Sat, 28 Sep 2024 15:39:24 +1000 Subject: [PATCH 08/14] feat(counters): Dynamic counter aliases. --- src/modules/counters/cog.py | 159 ++++++++++++++++++++--------- src/modules/counters/data.py | 23 +++++ src/modules/counters/migration.sql | 7 ++ 3 files changed, 140 insertions(+), 49 deletions(-) diff --git a/src/modules/counters/cog.py b/src/modules/counters/cog.py index 0e99aba9..39ba1071 100644 --- a/src/modules/counters/cog.py +++ b/src/modules/counters/cog.py @@ -68,7 +68,30 @@ def counter_cmd_factory( user_total=user_total, ) ) - return counter_cmd + + async def lb_cmd(cog, ctx: commands.Context, *, args: str = ''): + user = await ctx.channel.user() + await ctx.reply(await cog.formatted_lb(counter, args, int(user.id))) + + async def undo_cmd(cog, ctx: commands.Context): + userid = int(ctx.author.id) + channelid = int((await ctx.channel.user()).id) + _counter = await cog.fetch_counter(counter) + query = cog.data.CounterEntry.fetch_where( + counterid=_counter.counterid, + userid=userid, + ) + query.order_by('created_at', direction=ORDER.DESC) + query.limit(1) + results = await query + if not results: + await ctx.reply("Nothing to delete!") + else: + row = results[0] + await row.delete() + await ctx.reply("Undo successful!") + + return (counter_cmd, lb_cmd, undo_cmd) class CounterCog(LionCog): @@ -84,6 +107,7 @@ class CounterCog(LionCog): async def cog_load(self): self._load_twitch_methods(self.crocbot) + await self.load_counter_commands() await self.data.init() await self.load_counters() @@ -92,6 +116,55 @@ class CounterCog(LionCog): async def cog_unload(self): self._unload_twitch_methods(self.crocbot) + async def load_counter_commands(self): + rows = await self.data.CounterCommand.fetch_where() + for row in rows: + counter = await self.data.Counter.fetch(row.counterid) + counter_cb, lb_cb, undo_cb = counter_cmd_factory( + counter.name, + row.response + ) + cmds = [] + main_cmd = commands.command(name=row.name)(counter_cb) + cmds.append(main_cmd) + if row.lbname: + lb_cmd = commands.command(name=row.lbname)(lb_cb) + cmds.append(lb_cmd) + if row.undoname: + undo_cmd = commands.command(name=row.undoname)(undo_cb) + cmds.append(undo_cmd) + + for cmd in cmds: + self.add_twitch_command(self.crocbot, cmd) + + logger.info(f"(Re)Loaded {len(rows)} counter commands!") + + + + # commands = { + # 'stuff': ( + # 'stuffcounter', + # 'stufflb', + # "Good luck with {detailsorname}! We have done {total} stuffs {period_name}." + # ), + # 'water': ( + # 'water', + # 'waterlb', + # "Good job hydrating! We have had {total} cups of tea {period_name}." + # ), + # 'coffee': ( + # 'coffee', + # 'coffeelb', + # "Enjoy your {detailsorname}! We have had {total} cups of coffee {period_name}." + # ), + # 'tea': ( + # 'tea', + # 'tealb', + # "Enjoy your {detailsorname}! We have had {total} cups of tea this {period_name}." + # ), + # } + + async def cog_check(self, ctx): return True @@ -207,8 +280,43 @@ class CounterCog(LionCog): elif subcmd == 'clear': await self.reset_counter(name) await ctx.reply(f"'{name}' counter reset.") + elif subcmd == 'alias': + splits = args.split(maxsplit=3) if args else [] + counter = await self.fetch_counter(name) + rows = await self.data.CounterCommand.fetch_where(counterid=counter.counterid) + existing = rows[0] if rows else None + if existing and not args: + # Show current alias + await ctx.reply( + f"Counter '{name}' aliases: '!{existing.name}' to add to counter; " + f"'!{existing.lbname}' to view counter leaderboard; " + f"'!{existing.undoname}' to undo (your) last addition." + ) + elif len(splits) < 4: + # Show usage + await ctx.reply( + "USAGE: !counter alias -- " + "Response accepts keywords {total}, {period}, {period_name}, {detailsorname}, {user_total}." + ) + else: + # Create new alias + cmdname, lbname, undoname, response = splits + # Remove any existing alias + await self.data.CounterCommand.table.delete_where(name=cmdname) + + alias = await self.data.CounterCommand.create( + name=cmdname, + counterid=counter.counterid, + lbname=lbname, undoname=undoname, response=response + ) + await self.load_counter_commands() + await ctx.reply( + f"Alias created for counter '{name}': '!{alias.name}' to add to counter; " + f"'!{alias.lbname}' to view counter leaderboard; " + f"'!{alias.undoname}' to undo (your) last addition." + ) else: - await ctx.reply(f"Unrecognised subcommand {subcmd}. Supported subcommands: 'show', 'add', 'lb', 'clear'.") + await ctx.reply(f"Unrecognised subcommand {subcmd}. Supported subcommands: 'show', 'add', 'lb', 'clear', 'alias'.") async def parse_period(self, userid: int, periodstr: str, default=PERIOD.STREAM): if periodstr: @@ -263,50 +371,3 @@ class CounterCog(LionCog): return f"{counter} {period.value[-1]} leaderboard --- {lbstr}" else: return f"{counter} {period.value[-1]} leaderboard is empty!" - - # Misc actual counter commands - # TODO: Factor this out to a different module... - # TODO: Probably make these all alias commands - - tea_cmd = commands.command(name='tea')( - counter_cmd_factory( - 'tea', - "Enjoy your {detailsorname}! We have had {total} cups of tea {period_name}." - ) - ) - - @commands.command() - async def tealb(self, ctx: commands.Context, *, args: str = ''): - user = await ctx.channel.user() - await ctx.reply(await self.formatted_lb('tea', args, int(user.id))) - - coffee_cmd = commands.command(name='coffee')( - counter_cmd_factory( - 'coffee', - "Enjoy your {detailsorname}! We have had {total} cups of coffee {period_name}." - ) - ) - - @commands.command() - async def coffeelb(self, ctx: commands.Context, *, args: str = ''): - user = await ctx.channel.user() - await ctx.reply(await self.formatted_lb('coffee', args, int(user.id))) - - water_cmd = commands.command(name='water')( - counter_cmd_factory( - 'water', - "Good job hydrating! We have had {total} cups of tea {period_name}." - ) - ) - - @commands.command() - async def waterlb(self, ctx: commands.Context, *, args: str = ''): - user = await ctx.channel.user() - await ctx.reply(await self.formatted_lb('water', args, int(user.id))) - - stuff_cmd = commands.command(name='stuffcounter')( - counter_cmd_factory( - 'stuff', - "Good luck with {detailsorname}! We have done {total} stuffs {period_name}." - ) - ) diff --git a/src/modules/counters/data.py b/src/modules/counters/data.py index a7405053..59fa313a 100644 --- a/src/modules/counters/data.py +++ b/src/modules/counters/data.py @@ -49,3 +49,26 @@ class CounterData(Registry): context_str = String() details = String() + class CounterCommand(RowModel): + """ + Schema + ------ + CREATE TABLE counter_commands( + name TEXT PRIMARY KEY, + counterid INTEGER NOT NULL REFERENCES counters (counterid) ON UPDATE CASCADE ON DELETE CASCADE, + lbname TEXT, + undoname TEXT, + response TEXT NOT NULL + ); + """ + # NOTE: This table will be replaced by aliases soon anyway + # So no need to worry about integrity or future-proofing + _tablename_ = 'counter_commands' + _cache_ = {} + + name = String(primary=True) + counterid = Integer() + lbname = String() + undoname = String() + response = String() + diff --git a/src/modules/counters/migration.sql b/src/modules/counters/migration.sql index 3a0220c0..d9f9f543 100644 --- a/src/modules/counters/migration.sql +++ b/src/modules/counters/migration.sql @@ -1,2 +1,9 @@ ALTER TABLE counters ADD COLUMN category TEXT; ALTER TABLE counter_log ADD COLUMN details TEXT; +CREATE TABLE counter_commands( + name TEXT PRIMARY KEY, + counterid INTEGER NOT NULL REFERENCES counters (counterid) ON UPDATE CASCADE ON DELETE CASCADE, + lbname TEXT, + undoname TEXT, + response TEXT NOT NULL +); From 45b57b4eca65a4fba95f1b4a66392506894e10dc Mon Sep 17 00:00:00 2001 From: Interitio Date: Sat, 28 Sep 2024 16:58:40 +1000 Subject: [PATCH 09/14] (counters): Remove outdated comment. --- src/modules/counters/cog.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/modules/counters/cog.py b/src/modules/counters/cog.py index 39ba1071..8cf4ca3a 100644 --- a/src/modules/counters/cog.py +++ b/src/modules/counters/cog.py @@ -139,32 +139,6 @@ class CounterCog(LionCog): logger.info(f"(Re)Loaded {len(rows)} counter commands!") - - - # commands = { - # 'stuff': ( - # 'stuffcounter', - # 'stufflb', - # "Good luck with {detailsorname}! We have done {total} stuffs {period_name}." - # ), - # 'water': ( - # 'water', - # 'waterlb', - # "Good job hydrating! We have had {total} cups of tea {period_name}." - # ), - # 'coffee': ( - # 'coffee', - # 'coffeelb', - # "Enjoy your {detailsorname}! We have had {total} cups of coffee {period_name}." - # ), - # 'tea': ( - # 'tea', - # 'tealb', - # "Enjoy your {detailsorname}! We have had {total} cups of tea this {period_name}." - # ), - # } - - async def cog_check(self, ctx): return True From fc459ac0dd961f4276c96c053bfb67a7944cdd60 Mon Sep 17 00:00:00 2001 From: Interitio Date: Mon, 30 Sep 2024 19:02:06 +1000 Subject: [PATCH 10/14] fix(counters): Fix counter response period user. --- src/modules/counters/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/counters/cog.py b/src/modules/counters/cog.py index 8cf4ca3a..2508208f 100644 --- a/src/modules/counters/cog.py +++ b/src/modules/counters/cog.py @@ -35,7 +35,7 @@ def counter_cmd_factory( async def counter_cmd(cog, ctx: commands.Context, *, args: Optional[str] = None): userid = int(ctx.author.id) channelid = int((await ctx.channel.user()).id) - period, start_time = await cog.parse_period(userid, '', default=default_period) + period, start_time = await cog.parse_period(channelid, '', default=default_period) args = (args or '').strip(" 󠀀 ") splits = args.split(maxsplit=1) From 47a52d9600851cbc8d0ee96e5a2b62a8a3ec0625 Mon Sep 17 00:00:00 2001 From: Interitio Date: Sat, 5 Oct 2024 01:42:15 +1000 Subject: [PATCH 11/14] routine: Update voicefix pointer. --- src/modules/voicefix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/voicefix b/src/modules/voicefix index 5146e465..cca1c94b 160000 --- a/src/modules/voicefix +++ b/src/modules/voicefix @@ -1 +1 @@ -Subproject commit 5146e4651526e10fd34e6b16bfba0fb800abca73 +Subproject commit cca1c94bd50d76fecbb6c2a0d8d851bb00f812aa From d158aed2574b728bfa96ab4da9e422276e212352 Mon Sep 17 00:00:00 2001 From: Interitio Date: Sat, 5 Oct 2024 03:17:32 +1000 Subject: [PATCH 12/14] feat: Add target seeker. --- src/meta/CrocBot.py | 49 ++++++++++++++++++++++++++++++++++++ src/modules/shoutouts/cog.py | 35 ++++++++++++++++++++------ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/meta/CrocBot.py b/src/meta/CrocBot.py index a4742424..897bd03b 100644 --- a/src/meta/CrocBot.py +++ b/src/meta/CrocBot.py @@ -1,9 +1,12 @@ +from collections import defaultdict from typing import TYPE_CHECKING import logging +import twitchio from twitchio.ext import commands from twitchio.ext import pubsub +from twitchio.ext.commands.core import itertools from data import Database @@ -23,5 +26,51 @@ class CrocBot(commands.Bot): self.data = data self.pubsub = pubsub.PubSubPool(self) + self._member_cache = defaultdict(dict) + async def event_ready(self): logger.info(f"Logged in as {self.nick}. User id is {self.user_id}") + + async def event_join(self, channel: twitchio.Channel, user: twitchio.User): + self._member_cache[channel.name][user.name] = user + + async def event_message(self, message: twitchio.Message): + if message.channel and message.author: + self._member_cache[message.channel.name][message.author.name] = message.author + await self.handle_commands(message) + + async def seek_user(self, userstr: str, matching=True, fuzzy=True): + if userstr.startswith('@'): + matching = False + userstr = userstr.strip('@ ') + + result = None + if matching and len(userstr) >= 3: + lowered = userstr.lower() + full_matches = [] + for user in itertools.chain(*(cmems.values() for cmems in self._member_cache.values())): + matchstr = user.name.lower() + print(matchstr) + if matchstr.startswith(lowered): + result = user + break + if lowered in matchstr: + full_matches.append(user) + if result is None and full_matches: + result = full_matches[0] + print(result) + + if result is None: + lookup = userstr + elif result.id is None: + lookup = result.name + else: + lookup = None + + if lookup: + found = await self.fetch_users(names=[lookup]) + if found: + result = found[0] + + # No matches found + return result diff --git a/src/modules/shoutouts/cog.py b/src/modules/shoutouts/cog.py index 1ebf56d7..8f52dbd4 100644 --- a/src/modules/shoutouts/cog.py +++ b/src/modules/shoutouts/cog.py @@ -17,9 +17,19 @@ class ShoutoutCog(LionCog): and drop a follow! \ They {areorwere} streaming {game} at {channel} """ + COWO_SHOUTOUT = """ + We think that {name} is a great coworker and you should check them out for more productive vibes! \ + They {areorwere} streaming {game} at {channel} + """ + ART_SHOUTOUT = """ + We think that {name} is an awesome artist and you should check them out for cool art and cosy vibes! \ + They {areorwere} streaming {game} at {channel} + """ + def __init__(self, bot: LionBot): self.bot = bot - self.crocbot = bot.crocbot + self.crocbot: CrocBot = bot.crocbot + self.data = bot.db.load_registry(ShoutoutData()) self.loaded = asyncio.Event() @@ -59,19 +69,28 @@ class ShoutoutCog(LionCog): return replace_multiple(text, mapping) @commands.command(aliases=['so']) - async def shoutout(self, ctx: commands.Context, user: twitchio.User): + async def shoutout(self, ctx: commands.Context, target: str, typ: Optional[str]=None): # Make sure caller is mod/broadcaster # Lookup custom shoutout for this user # If it exists use it, otherwise use default shoutout if (ctx.author.is_mod or ctx.author.is_broadcaster): - data = await self.data.CustomShoutout.fetch(int(user.id)) - if data: - shoutout = data.content + user = await self.crocbot.seek_user(target) + if user is None: + await ctx.reply(f"Couldn't resolve '{target}' to a valid user.") else: - shoutout = self.DEFAULT_SHOUTOUT - formatted = await self.format_shoutout(shoutout, user) - await ctx.reply(formatted) + data = await self.data.CustomShoutout.fetch(int(user.id)) + if data: + shoutout = data.content + elif typ == 'cowo': + shoutout = self.COWO_SHOUTOUT + elif typ == 'art': + shoutout = self.ART_SHOUTOUT + else: + shoutout = self.DEFAULT_SHOUTOUT + formatted = await self.format_shoutout(shoutout, user) + await ctx.reply(formatted) # TODO: How to /shoutout with lib? + # TODO Shoutout queue @commands.command() async def editshoutout(self, ctx: commands.Context, user: twitchio.User, *, text: str): From 81e25e7efc9634ac41851516dd020dcce0b82981 Mon Sep 17 00:00:00 2001 From: Interitio Date: Sat, 5 Oct 2024 04:07:46 +1000 Subject: [PATCH 13/14] feat(vcroles): Add voice autoroles. --- data/schema.sql | 10 ++ src/modules/__init__.py | 1 + src/modules/voiceroles/__init__.py | 7 ++ src/modules/voiceroles/cog.py | 166 +++++++++++++++++++++++++++++ src/modules/voiceroles/data.py | 27 +++++ 5 files changed, 211 insertions(+) create mode 100644 src/modules/voiceroles/__init__.py create mode 100644 src/modules/voiceroles/cog.py create mode 100644 src/modules/voiceroles/data.py diff --git a/data/schema.sql b/data/schema.sql index 51d5fb5f..3313b687 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -1486,6 +1486,16 @@ CREATE UNIQUE INDEX channel_tags_channelid_name ON channel_tags (channelid, name -- }}} +-- Voice Roles {{{ +CREATE TABLE voice_roles( + voice_role_id SERIAL PRIMARY KEY, + channelid BIGINT NOT NULL, + roleid BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX voice_role_channels on voice_roles (channelid); + +-- }}} -- Analytics Data {{{ diff --git a/src/modules/__init__.py b/src/modules/__init__.py index 2061986c..9e6bb1fd 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -30,6 +30,7 @@ active_discord = [ '.nowdoing', '.shoutouts', '.tagstrings', + '.voiceroles', ] async def setup(bot): diff --git a/src/modules/voiceroles/__init__.py b/src/modules/voiceroles/__init__.py new file mode 100644 index 00000000..a8022ffb --- /dev/null +++ b/src/modules/voiceroles/__init__.py @@ -0,0 +1,7 @@ +import logging + +logger = logging.getLogger(__name__) + +async def setup(bot): + from .cog import VoiceRoleCog + await bot.add_cog(VoiceRoleCog(bot)) diff --git a/src/modules/voiceroles/cog.py b/src/modules/voiceroles/cog.py new file mode 100644 index 00000000..cbaa6b5c --- /dev/null +++ b/src/modules/voiceroles/cog.py @@ -0,0 +1,166 @@ +from collections import defaultdict +from typing import Optional +import asyncio +from cachetools import FIFOCache +from weakref import WeakValueDictionary + +import discord +from discord.abc import GuildChannel +from discord.ext import commands as cmds +from discord import app_commands as appcmds + +from meta import LionBot, LionCog, LionContext +from meta.logger import log_wrap +from meta.errors import ResponseTimedOut, SafeCancellation, UserInputError +from utils.ui import Confirm + +from . import logger +from .data import VoiceRoleData + + +class VoiceRoleCog(LionCog): + def __init__(self, bot: LionBot): + self.bot = bot + self.data = bot.db.load_registry(VoiceRoleData()) + + self._event_locks: WeakValueDictionary[tuple[int, int], asyncio.Lock] = WeakValueDictionary() + + async def cog_load(self): + await self.data.init() + + @LionCog.listener('on_voice_state_update') + @log_wrap(action='Voice Role Update') + async def voicerole_update(self, member: discord.Member, + before: discord.VoiceState, after: discord.VoiceState): + if member.bot: + return + + after_channel = after.channel + before_channel = before.channel + if after_channel == before_channel: + return + + task_key = (member.guild.id, member.id) + async with self.event_lock(task_key): + # Get the roles of the channel they left to remove + # Get the roles of the channel they are joining to add + # Use a set difference to remove the roles to be added from the ones to remove + if before_channel is not None: + leaving_roles = await self.get_roles_for(before_channel.id) + else: + leaving_roles = [] + + if after_channel is not None: + gaining_roles = await self.get_roles_for(after_channel.id) + else: + gaining_roles = [] + + to_remove = [] + for role in leaving_roles: + if role in member.roles and role not in gaining_roles and role.is_assignable(): + to_remove.append(role) + + to_add = [] + for role in gaining_roles: + if role not in member.roles and role.is_assignable(): + to_add.append(role) + + if to_remove: + await member.remove_roles(*to_remove, reason="Removing voice channel associated roles.") + if to_add: + await member.add_roles(*to_add, reason="Adding voice channel associated roles.") + + logger.info( + f"Voice roles removed {len(to_remove)} roles " + f"and added {len(to_add)} roles to " + ) + + async def get_roles_for(self, channelid: int) -> list[discord.Role]: + """ + Get the voice roles associated to the given channel, as a list. + + Returns an empty list if there are no associated voice roles. + """ + rows = await self.data.VoiceRole.fetch_where(channelid=channelid) + channel = self.bot.get_channel(channelid) + if not channel: + raise ValueError("Provided voice role target channel is not in cache.") + + target_roles = [] + for row in rows: + role = channel.guild.get_role(row.roleid) + if role is not None: + target_roles.append(role) + + return target_roles + + def event_lock(self, key) -> asyncio.Lock: + """ + Get an asyncio.Lock for the given key. + + Guarantees sequential event handling. + """ + lock = self._event_locks.get(key, None) + if lock is None: + lock = self._event_locks[key] = asyncio.Lock() + logger.debug(f"Getting video event lock {key} (locked: {lock.locked()})") + return lock + + + # -------- Commands -------- + @cmds.hybrid_group( + name='voiceroles', + description="Base command group for voice channel -> role associationes." + ) + @appcmds.default_permissions(manage_channels=True) + async def voicerole_group(self, ctx: LionContext): + ... + + @voicerole_group.command( + name="link", + description="Link a given voice channel with a given role." + ) + @appcmds.describe( + channel="The voice channel to link.", + role="The associated role to give to members joining the voice channel." + ) + async def voicerole_link(self, ctx: LionContext, + channel: discord.VoiceChannel, + role: discord.Role): + if not ctx.interaction: + return + if not channel.permissions_for(ctx.author).manage_channels: + await ctx.error_reply(f"You don't have the manage channels permission in {channel.mention}") + return + if not ctx.author.guild_permissions.manage_roles or not (role < ctx.author.top_role): + await ctx.error_reply(f"You don't have the permission to manage this role!") + return + + await self.data.VoiceRole.table.insert(channelid=channel.id, roleid=role.id) + await ctx.reply("Voice role associated!") + + @voicerole_group.command( + name="unlink", + description="Unlink a given voice channel from a given role." + ) + @appcmds.describe( + channel="The voice channel to unlink.", + role="The role to remove from this voice channel." + ) + async def voicerole_unlink(self, ctx: LionContext, + channel: discord.VoiceChannel, + role: discord.Role): + if not ctx.interaction: + return + if not channel.permissions_for(ctx.author).manage_channels: + await ctx.error_reply(f"You don't have the manage channels permission in {channel.mention}") + return + if not ctx.author.guild_permissions.manage_roles or not (role < ctx.author.top_role): + await ctx.error_reply(f"You don't have the permission to manage this role!") + return + + await self.data.VoiceRole.table.delete_where(channelid=channel.id, roleid=role.id) + await ctx.reply("Voice role disassociated!") + + # TODO: Display and visual editing of roles. + diff --git a/src/modules/voiceroles/data.py b/src/modules/voiceroles/data.py new file mode 100644 index 00000000..19c4cc85 --- /dev/null +++ b/src/modules/voiceroles/data.py @@ -0,0 +1,27 @@ +from data import Registry, RowModel +from data.columns import Integer, Timestamp + + +class VoiceRoleData(Registry): + class VoiceRole(RowModel): + """ + Schema + ------ + CREATE TABLE voice_roles( + voice_role_id SERIAL PRIMARY KEY, + channelid BIGINT NOT NULL, + roleid BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX voice_role_channels on voice_roles (channelid); + """ + # TODO: Worth associating a guildid to this as well? Denormalises though + # Makes more theoretical sense to associated configurable channels to the guilds in a join table. + _tablename_ = 'voice_roles' + _cache_ = {} + + voice_role_id = Integer(primary=True) + channelid = Integer() + roleid = Integer() + + created_at = Timestamp() From 63152f3475af5f25e1dade1ba20660babdef73d6 Mon Sep 17 00:00:00 2001 From: Interitio Date: Sat, 5 Oct 2024 07:50:43 +1000 Subject: [PATCH 14/14] routine: Use ssh url for voicefix submodule. --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index f3a33f00..c02a39a4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,7 +6,7 @@ url = git@github.com:Intery/CafeHelper-Skins.git [submodule "src/modules/voicefix"] path = src/modules/voicefix - url = https://github.com/Intery/StudyLion-voicefix.git + url = git@github.com:Intery/StudyLion-voicefix.git [submodule "src/modules/streamalerts"] path = src/modules/streamalerts url = https://github.com/Intery/StudyLion-streamalerts.git