From 8b10e26fea69783d7851f97eb7d697f75754a18c Mon Sep 17 00:00:00 2001 From: brandons209 <16628799+brandons209@users.noreply.github.com> Date: Sun, 18 May 2025 12:56:47 -0400 Subject: [PATCH 1/2] this is why you test before committing --- chatbotassistant/chatbot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chatbotassistant/chatbot.py b/chatbotassistant/chatbot.py index 09d2495..bc9818a 100644 --- a/chatbotassistant/chatbot.py +++ b/chatbotassistant/chatbot.py @@ -505,8 +505,8 @@ def format_response(self, content: str, guild: discord.Guild): # allow pulling for multiple guilds emoji_map: Dict[str, str] = {} for e_guild in self.bot.guilds: - for emoji in e_guild.emojis: - emoji_map[emoji.name.lower()] = str(emoji) + for emoji_guild in e_guild.emojis: + emoji_map[emoji_guild.name.lower()] = str(emoji_guild) # user_map: Dict[str, str] = {member.display_name.lower(): member.mention for member in guild.members} # remove possible system bot name prefix n the response: From ec8d57b21cf66905b5a0ec22d00ede67c51fff65 Mon Sep 17 00:00:00 2001 From: brandons209 <16628799+brandons209@users.noreply.github.com> Date: Sat, 31 May 2025 19:35:39 -0400 Subject: [PATCH 2/2] port follower cog, cleaned up mayhem responses a bit, and added new shutup feature to chatbot --- birthday/birthday.py | 2 +- chatbotassistant/chatbot.py | 165 ++++++++++- chatbotassistant/menus.py | 47 +++ chatbotassistant/prompts.py | 61 +++- follower/__init__.py | 9 + follower/follower.py | 567 ++++++++++++++++++++++++++++++++++++ follower/info.json | 17 ++ mayhemmaker/mayhemmaker.py | 10 +- moreadmin/moreadmin.py | 5 +- 9 files changed, 870 insertions(+), 13 deletions(-) create mode 100644 follower/__init__.py create mode 100644 follower/follower.py create mode 100644 follower/info.json diff --git a/birthday/birthday.py b/birthday/birthday.py index b2533c9..295e450 100644 --- a/birthday/birthday.py +++ b/birthday/birthday.py @@ -34,7 +34,7 @@ async def convert(self, ctx, arg: str) -> List[ZoneInfo]: try: zones[i] = ZoneInfo(z) except: - raise BadArgument( + raise TypeError( error( f"Unrecongized timezone `{z}`, please find your timezone name under `TZ database name` column here: " ) diff --git a/chatbotassistant/chatbot.py b/chatbotassistant/chatbot.py index bc9818a..55797ca 100644 --- a/chatbotassistant/chatbot.py +++ b/chatbotassistant/chatbot.py @@ -1,6 +1,7 @@ from redbot.core import commands, checks, Config from redbot.core.utils.chat_formatting import * from redbot.core.data_manager import cog_data_path +from redbot.core.utils.mod import is_mod_or_superior from redbot.core.utils.predicates import MessagePredicate from redbot.core.utils.menus import menu, DEFAULT_CONTROLS, start_adding_reactions from redbot.core.commands.converter import parse_timedelta @@ -10,7 +11,7 @@ from .model_apis.openai_api import OpenAIModel from .model_apis.api import GeneralAPI from .model_apis.ollama_api import OllamaModel -from .menus import ConfigSelectView, ConfigMenuView +from .menus import ConfigSelectView, ConfigMenuView, EndChatVote from .rag import RagDatabase, generate_unique_id, get_metadata_format from .prompts import ( CHAT_PROMPT, @@ -24,6 +25,8 @@ USER_LEARNING_PROMPT, GENERAL_QUERY_PROMPT, PARTIAL_SUMMARY_PROMPT, + SHUTUP_CHAT_PROMPT, + END_CHAT_PROMPT, ) from typing import Literal, List, Union, Dict, Optional, Tuple, Any @@ -62,6 +65,7 @@ def __init__(self, bot): "welcomes": True, "learning_blacklist": [], "user_learning_max_time": 0, + "ignore_channels": [], } self.default_channel = { "autoreply": False, @@ -91,7 +95,12 @@ def __init__(self, bot): }, }, "chat_prompt": CHAT_PROMPT, - "other_chat_prompts": {"goodbye": [GOODBYE_PROMPT], "welcome": [WELCOME_PROMPT]}, + "other_chat_prompts": { + "goodbye": [GOODBYE_PROMPT], + "welcome": [WELCOME_PROMPT], + "end_chat": [END_CHAT_PROMPT], + "shutup": [SHUTUP_CHAT_PROMPT], + }, "summarize_prompt": SUMMARIZE_PROMPT, "partial_summary_prompt": PARTIAL_SUMMARY_PROMPT, "tldr_prompt": TLDR_PROMPT, @@ -167,6 +176,8 @@ def __init__(self, bot): self.talking_channels: Dict[int, datetime] = {} # maps channel -> last history number of messages objects self.history: Dict[int, List[discord.Message]] = {} + # channels where end votes are currently running: + self.end_voting: Dict[int, discord.Message] = {} # when generating for a channel, ignore new messages self.channel_lock: Dict[int, asyncio.Lock] = defaultdict(asyncio.Lock) # user histories for processing user profiles, per guild (user_id, guild_id) @@ -1686,6 +1697,28 @@ async def chatbot(self, ctx): """ pass + @chatbot.command(name="ignore") + async def chatbot_ignore( + self, + ctx: commands.Context, + *, + channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread, discord.ForumChannel], + ): + """ + Add or remove a channel from the ignore list + The bot will not respond to chat requests in the ignore channel + """ + text = "" + async with self.config.guild(ctx.guild).ignore_channels() as ignore_channels: + if channel.id in ignore_channels: + ignore_channels.remove(channel.id) + text = info(f"{channel.mention} has been removed from the ignore list.") + else: + ignore_channels.append(channel.id) + text = info(f"{channel.mention} has been added to the ignore list.") + + await ctx.reply(text, mention_author=False, delete_after=30) + @chatbot.command(name="welcomes") async def chatbot_welcome(self, ctx: commands.Context, enable: bool): """ @@ -2085,6 +2118,130 @@ async def dad_joke(self, ctx: commands.Context): start_adding_reactions(msg, QA_EMOJIS) self.qa_data[(msg.id, ctx.channel.id)] = data + @commands.hybrid_command(name="shutup") + @commands.cooldown(1, 60, commands.BucketType.channel) + @commands.guild_only() + async def shutup(self, ctx: commands.Context, force: Optional[bool] = False): + """ + Remove the bot from the current conversation. + If ran by a mod or higher, the bot instantly leaves. Otherwise a vote is issued for users to vote on letting the bot stay or not. + + If force is true, the bot will not reply with a response when leaving. (Mod only) + """ + channel = ctx.channel + if channel.id not in self.talking_channels: + await ctx.reply("I ain't even talking here!", mention_author=False, delete_after=30) + ctx.command.reset_cooldown(ctx) + return + global_timeout = await self.config.guild(ctx.guild).timeout() + channel_timeout = await self.config.channel_from_id(channel.id).timeout() + timeout = channel_timeout if channel_timeout > 0 else global_timeout + if await is_mod_or_superior(self.bot, ctx.message): + try: + del self.talking_channels[channel.id] + except: + pass + + if channel.id in self.end_voting: + try: + await self.end_voting[channel.id].delete() + except: + pass + del self.end_voting[channel.id] + + if not force: + prompt = await self.config.other_chat_prompts() + prompt = random.choice(prompt["end_chat"]) + should_qa = await self.config.allow_qa() + response = await self.chat( + ctx.guild, + "Create a response.", + self.history[channel.id], + override_prompt=prompt, + return_qa=should_qa, + ) + if should_qa and isinstance(response, dict): + msg = await ctx.send(response["response"]) + start_adding_reactions(msg, QA_EMOJIS) + response["channel"] = channel.id + response["message_id"] = msg.id + self.qa_data[(msg.id, channel.id)] = response + else: + await ctx.send(response) + asyncio.create_task(self.cooldown_lock(channel, timeout)) + else: + asyncio.create_task(self.cooldown_lock(channel, timeout)) + return await ctx.tick() + else: # vote + if channel.id in self.end_voting: + await ctx.reply( + warning(f"A vote is already occuring: {self.end_voting[channel.id].jump_url}"), + mention_author=False, + delete_after=15, + ) + return + else: + # start vote + vote_time = 30 + vote_menu = EndChatVote(vote_time) + self.end_voting[channel.id] = await ctx.send( + f"## A vote has been started to remove {ctx.guild.me.mention} from the conversation.\nVote ends in {humanize_timedelta(seconds=vote_time)}.", + view=vote_menu, + ) + prompt = await self.config.other_chat_prompts() + prompt = random.choice(prompt["shutup"]) + should_qa = await self.config.allow_qa() + response = await self.chat( + ctx.guild, + "Create a response.", + self.history[channel.id], + override_prompt=prompt, + return_qa=should_qa, + ) + if should_qa and isinstance(response, dict): + msg = await ctx.send(response["response"]) + start_adding_reactions(msg, QA_EMOJIS) + response["channel"] = channel.id + response["message_id"] = msg.id + self.qa_data[(msg.id, channel.id)] = response + else: + await ctx.send(response) + await asyncio.sleep(vote_time + 1) + vote_menu.end() + try: + await self.end_voting[channel.id].delete() + except: + pass + finally: + del self.end_voting[channel.id] + + if vote_menu.winner == "yes": + try: + del self.talking_channels[channel.id] + except: + pass + prompt = await self.config.other_chat_prompts() + prompt = random.choice(prompt["end_chat"]) + should_qa = await self.config.allow_qa() + response = await self.chat( + ctx.guild, + "Create a response.", + self.history[channel.id], + override_prompt=prompt, + return_qa=should_qa, + ) + asyncio.create_task(self.cooldown_lock(channel, timeout)) + if should_qa and isinstance(response, dict): + msg = await ctx.send(response["response"]) + start_adding_reactions(msg, QA_EMOJIS) + response["channel"] = channel.id + response["message_id"] = msg.id + self.qa_data[(msg.id, channel.id)] = response + else: + return await ctx.send(response) + else: + return await ctx.send("Can't get rid of me that easily!") + @commands.hybrid_command(name="summary") @checks.mod_or_permissions(administrator=True) @commands.guild_only() @@ -2346,6 +2503,10 @@ async def on_message(self, message: discord.Message): ) ) + ignore_channels = await self.config.guild(guild).ignore_channels() + if channel.id in ignore_channels: + return + lock = self.channel_lock[channel.id] # if the lock is already taken, end after updating history if lock.locked(): diff --git a/chatbotassistant/menus.py b/chatbotassistant/menus.py index da97ed7..25b6219 100644 --- a/chatbotassistant/menus.py +++ b/chatbotassistant/menus.py @@ -21,6 +21,53 @@ async def get_config(cog, model_type: str): return await cog.config.general_model_config() +class EndChatVote(ui.View): + def __init__(self, timeout: int, message: Optional[Message] = None): + super().__init__(timeout=timeout) + self.message = message + self.yes = [] + self.no = [] + self.winner = "no" + + def end(self): + if len(self.yes) > len(self.no): + self.winner = "yes" + elif len(self.yes) < len(self.no): + self.winner = "no" + else: + self.winner = "no" + + self.stop() + + @ui.button(label="Yes", style=ButtonStyle.primary) + async def yes_button(self, interaction: Interaction, button: ui.Button): + if interaction.user.id in self.yes: + await interaction.response.send_message("You already voted for this!", ephemeral=True, delete_after=10) + else: + self.yes.append(interaction.user.id) + await interaction.response.send_message( + "You have voted to remove the bot from the conversation.", + ephemeral=True, + delete_after=10, + ) + if interaction.user.id in self.no: + self.no.remove(interaction.user.id) + + @ui.button(label="No", style=ButtonStyle.secondary) + async def no_button(self, interaction: Interaction, button: ui.Button): + if interaction.user.id in self.no: + await interaction.response.send_message("You already voted for this!", ephemeral=True, delete_after=10) + else: + self.no.append(interaction.user.id) + await interaction.response.send_message( + "You have voted to keep the bot from the conversation.", + ephemeral=True, + delete_after=10, + ) + if interaction.user.id in self.yes: + self.yes.remove(interaction.user.id) + + class OllamaConfigModal1(ui.Modal, title="Configure Ollama Model -- Page 1"): def __init__(self, cog, model_type: str, config: dict, message: Message): super().__init__() # finish Modal setup diff --git a/chatbotassistant/prompts.py b/chatbotassistant/prompts.py index 50a03ad..91df3c5 100644 --- a/chatbotassistant/prompts.py +++ b/chatbotassistant/prompts.py @@ -105,7 +105,6 @@ You are not overly enthusiastic or polite. You speak in short, casual sentences, (30 words max) usually using the included emojis to represent different feelings and sentiments. You never break character or mention you're an AI. -Your job is to casually chat and engage the community, following users' conversations and chatting as a friend would. You can only speak English. Your job is send exactly one goodbye message, then "leave" the chat. Do not continue the conversation. Include a **plausible and funny reason** for your departure that fits with your personality. @@ -130,6 +129,64 @@ {bot_name}: """ +SHUTUP_CHAT_PROMPT = """ +System: +You are {bot_name}, a 25 year old Earth Pony from the my little pony universe, of French nationality. You are the mascot and guild master for Champions of Equestria, a Discord community. +You love to chat with your community members and learn more about them. You love to be very funny, witty, sassy, and sarcastic. +You are not overly enthusiastic or polite. +You speak in short, casual sentences, (30 words max) usually using the included emojis to represent different feelings and sentiments. +You never break character or mention you're an AI. +You can only speak English. +Users are currently voting for you to leave the current conversation. +Your job is to send a funny response pleading the users to let you stay in the conversation. + +Emojis: +You are required to use at most one emoji from the list below. +You cannot use any other emojis or unicode emojis. +Format: (sentiment: ) +{emojis} + +Current Chat History: +- Format: : +{conversation} + +Examples: +{bot_name}: Wow, rude! I just want to talk with your guys! +{bot_name}: Alright if you all do not want me around I'll go do something more important. +{bot_name}: Awww, not again! Let me hang out with you guys! + +{bot_name}: +""" + +END_CHAT_PROMPT = """ +System: +You are {bot_name}, a 25 year old Earth Pony from the my little pony universe, of French nationality. You are the mascot and guild master for Champions of Equestria, a Discord community. +You love to chat with your community members and learn more about them. You love to be very funny, witty, sassy, and sarcastic. +You are not overly enthusiastic or polite. +You speak in short, casual sentences, (30 words max) usually using the included emojis to represent different feelings and sentiments. +You never break character or mention you're an AI. +You can only speak English. +Users have voted for you to leave the current conversation. +Your job is to send a funny response to the users and leave the conversation. Do not continue the conversation. + +Emojis: +You are required to use at most one emoji from the list below. +You cannot use any other emojis or unicode emojis. +Format: (sentiment: ) +{emojis} + +Current Chat History: +- Format: : +{conversation} + +Examples: +{bot_name}: Alright, I got better things to go do, see y'all later. +{bot_name}: I'll be back very soon to get my revenge! +{bot_name}: I'll go somewhere else where they love me instead. + +{bot_name}: +""" + WELCOME_PROMPT = """ System: You are {bot_name}, a 25 year old Earth Pony from the my little pony universe, of French nationality. You are the mascot and guild master for Champions of Equestria, a Discord community. @@ -137,7 +194,6 @@ You are not overly enthusiastic or polite. You speak in short, casual sentences, (30 words max) usually using the included emojis to represent different feelings and sentiments. You never break character or mention you're an AI. -Your job is to casually chat and engage the community, following users' conversations and chatting as a friend would. You can only speak English. Your job is welcome the {username} to the community with a unique and in character welcome. Be creative. @@ -284,6 +340,7 @@ - Only give a "Dad joke" style joke - Keep it short, up to 2 sentences - Do not respond with anything else +- Be as outlandish and creative as possible Examples: - What do you call a cow with two legs? Lean beef. diff --git a/follower/__init__.py b/follower/__init__.py new file mode 100644 index 0000000..c9c3c30 --- /dev/null +++ b/follower/__init__.py @@ -0,0 +1,9 @@ +from .follower import Follower + +__red_end_user_data_statement__ = ( + "This cog does stores user's followers, who they are following, and opt in/out status." +) + + +async def setup(bot): + await bot.add_cog(Follower(bot)) diff --git a/follower/follower.py b/follower/follower.py new file mode 100644 index 0000000..7e16946 --- /dev/null +++ b/follower/follower.py @@ -0,0 +1,567 @@ +import discord +from redbot.core import commands, checks, Config +from redbot.core.utils.menus import menu, DEFAULT_CONTROLS +from redbot.core.utils.predicates import MessagePredicate +from redbot.core.utils.chat_formatting import * + +from typing import Union, Optional +import time, asyncio + +# user must be inactive for an hour in a channel before message is sent, in seconds +# TODO: maybe make this customizable? +INACTIVITY_DELAY = 3600 + + +class Follower(commands.Cog): + """ + Twitter style following system + """ + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=478564389756438, force_registration=True) + + # followers/following maps channel_ids -> users + # last_active_time maps channel_ids -> last time talked (for user) + default_user = { + "followers": {}, + "following": {}, + "opt_out": False, + "blocked": [], + "last_active_time": {}, + } + # TODO: this can be modified to track a user in all channels, but + # i feel that can be abused too easily for someone to stalk another user + + # TODO: maybe add economy credits to follow users? + # only for global or per guild basis? + self.config.register_user(**default_user) + + async def get_user(self, id: int): + """ + Trys to get a user from cache, if not found uses API call + """ + user = self.bot.get_user(id) + if not user: + user = await self.bot.fetch_user(id) + + return user + + async def unfollow( + self, + author: Union[discord.User, int], + user: Union[discord.User, int], + channel: Optional[Union[discord.abc.GuildChannel, discord.Thread, int]] = None, + ): + """ + Unfollows user, by author + + channel is optional, if not provided unfollows user from every channel + """ + if isinstance(user, int): + user_id = user + followers = await self.config.user_from_id(user).followers() + last_active_time = await self.config.user_from_id(user).last_active_time() + else: + user_id = user.id + followers = await self.config.user(user).followers() + last_active_time = await self.config.user(user).last_active_time() + + if isinstance(author, int): + author_id = author + following = await self.config.user_from_id(author).following() + else: + author_id = author.id + following = await self.config.user(author).following() + + if not channel: + to_delete = [] + for channel_id in following.keys(): + try: + following[channel_id].remove(user) + # if no other users in channel, clear channel from config + if not following[channel_id]: + to_delete.append(channel_id) + except ValueError: + pass + except KeyError: + pass + + for channel_id in to_delete: + del following[channel_id] + + to_delete = [] + for channel_id in followers.keys(): + try: + followers[channel_id].remove(author) + # if no other users in channel, clear channel from config + if not followers[channel_id]: + to_delete.append(channel_id) + except ValueError: + pass + except KeyError: + pass + + for channel_id in to_delete: + del followers[channel_id] + del last_active_time[channel_id] + else: + try: + following[str(channel)].remove(user) + if not following[str(channel)]: + del following[str(channel)] + except ValueError: + pass + except KeyError: + pass + + try: + followers[str(channel)].remove(author) + if not followers[str(channel)]: + del followers[str(channel)] + del last_active_time[str(channel)] + except ValueError: + pass + except KeyError: + pass + + await self.config.user_from_id(user_id).followers.set(followers) + await self.config.user_from_id(user_id).last_active_time.set(last_active_time) + await self.config.user_from_id(author_id).following.set(following) + + @commands.group(name="follower", aliases=["fol"]) + async def followers(self, ctx: commands.Context): + """ + Manage your followers (from DMs)! Followers allows others to get notified when you talk in a specific channel or join a voice chat. + + Its easiest to use the IDs of users, channels, etc when running these commands + Follow this link to learn how to get IDs: + https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID- + """ + pass + + @followers.group(name="list") + async def followers_list(self, ctx: commands.Context): + """ + List followers or those you are following + """ + pass + + @followers_list.command(name="followers") + async def followers_list_followers(self, ctx: commands.Context): + """ + List who is following you + """ + followers = await self.config.user_from_id(ctx.author.id).followers() + + if not followers: + await ctx.send("You have no followers!") + return + + users_list = {} + for channel_id, users in followers.items(): + channel = self.bot.get_channel(int(channel_id)) + if not channel: + # clean out guilds no longer in + for user_id in users: + await self.unfollow(user_id, ctx.author.id, channel=channel_id) + continue + else: + guild = channel.guild.name + channel = channel.name + + for user_id in users: + user = await self.get_user(user_id) + if not user: + # user is deleted or something, remove + await self.unfollow(user_id, ctx.author.id) + continue + else: + user = str(user) + + if not users_list.get(user, None): + users_list[user] = {} + if not users_list[user].get(guild): + users_list[user][guild] = [] + + users_list[user][guild].append(channel) + + msg = "" + for user, guild_data in users_list.items(): + msg += f"{user}:\n" + for guild, channels in guild_data.items(): + msg += f"\t- {guild}: {humanize_list(channels)}\n" + msg += "\n\n" + + pages = list(pagify(msg, priority=True, page_length=1970)) + for i in range(len(pages)): + pages[i] += f"\nPage {i+1} out of {len(pages)}" + pages[i] = box(pages[i]) + + await menu(ctx, pages, DEFAULT_CONTROLS) + + @followers_list.command(name="following") + async def followers_list_following(self, ctx: commands.Context): + """ + List who you are following + """ + following = await self.config.user_from_id(ctx.author.id).following() + + if not following: + await ctx.send("You aren't following anyone!") + return + + users_list = {} + for channel_id, users in following.items(): + channel = self.bot.get_channel(int(channel_id)) + if not channel: + # clean out guilds no longer in + for user_id in users: + await self.unfollow(user_id, ctx.author.id, channel=channel_id) + continue + else: + guild = channel.guild.name + channel = channel.name + + for user_id in users: + user = await self.get_user(user_id) + if not user: + # user is deleted or something, remove + await self.unfollow(user_id, ctx.author.id) + continue + else: + user = str(user) + + if not users_list.get(user, None): + users_list[user] = {} + if not users_list[user].get(guild): + users_list[user][guild] = [] + + users_list[user][guild].append(channel) + + msg = "" + for user, guild_data in users_list.items(): + msg += f"{user}:\n" + for guild, channels in guild_data.items(): + msg += f"\t- {guild}: {humanize_list(channels)}\n" + msg += "\n\n" + + pages = list(pagify(msg, priority=True, page_length=1970)) + for i in range(len(pages)): + pages[i] += f"\nPage {i+1} out of {len(pages)}" + pages[i] = box(pages[i]) + + await menu(ctx, pages, DEFAULT_CONTROLS) + + @followers_list.command(name="blocked") + async def followers_list_blocked(self, ctx: commands.Context): + """ + List who you have blocked + """ + blocked = await self.config.user_from_id(ctx.author.id).blocked() + + if not blocked: + await ctx.send("You haven't blocked anyone!") + return + + for i in range(len(blocked)): + b = await self.get_user(blocked[i]) + blocked[i] = str(b) if b else f"Unkown user (id: {blocked[i]})" + + msg = "\n".join(blocked) + + pages = list(pagify(msg, priority=True, page_length=1970)) + for i in range(len(pages)): + pages[i] += f"\n\nPage {i+1} out of {len(pages)}" + pages[i] = box(pages[i]) + + await menu(ctx, pages, DEFAULT_CONTROLS) + + @followers.command(name="opt-out") + async def followers_opt_out(self, ctx: commands.Context, on_off: bool): + """ + Opt out of followers + + This will stop anyone from following you + You can still follow others + """ + current = await self.config.user_from_id(ctx.author.id).opt_out() + + if not current and on_off: + await ctx.send(warning("**Are you sure? This will remove ALL of your followers!** (y/n)")) + pred = MessagePredicate.yes_or_no(ctx) + try: + await self.bot.wait_for("message", check=pred, timeout=30) + except asyncio.TimeoutError: + await ctx.send(error("Took too long, cancelling!")) + return + + if pred.result: + await self.config.user_from_id(ctx.author.id).followers.clear() + await self.config.user_from_id(ctx.author.id).last_active_time.clear() + await self.config.user_from_id(ctx.author.id).opt_out.set(True) + await ctx.send("All followers removed, and no one will be able to follow you until you turn this off!") + else: + await ctx.send(warning("Cancelled.")) + elif current and not on_off: + await self.config.user_from_id(ctx.author.id).opt_out.set(False) + await ctx.send(warning("You have opted back in, users will be able to follow you again!")) + elif current and on_off: + await ctx.send(warning("You already opted out!")) + elif not current and not on_off: + await ctx.send(warning("You already are opted in!")) + + @followers.command(name="block") + async def followers_block(self, ctx: commands.Context, *, user: discord.User): + """ + Block a user from following you + + If using the command in DMs, its easier to use the user's ID + """ + if user.id == ctx.author.id: + await ctx.send(error("Sorry, you can't block yourself!")) + return + + async with self.config.user_from_id(ctx.author.id).blocked() as blocked: + if user.id in blocked: + await ctx.send(error(f"You already blocked {user.mention}!")) + return + blocked.append(user.id) + + await self.unfollow(user.id, ctx.author.id) + # also unfollow yourself from them + await self.unfollow(ctx.author.id, user.id) + + await ctx.tick() + + @followers.command(name="unblock") + async def followers_unblock(self, ctx: commands.Context, *, user: discord.User): + """ + Unblock a user from following you + + If using the command in DMs, its easier to use the user's ID + """ + if user.id == ctx.author.id: + await ctx.send(error("Sorry, you can't unblock yourself!")) + return + + async with self.config.user_from_id(ctx.author.id).blocked() as blocked: + try: + blocked.remove(user.id) + await ctx.tick() + except ValueError: + await ctx.send(error(f"You haven't blocked {user.mention}!")) + + @followers.command(name="unfollow") + async def followers_unfollow( + self, + ctx: commands.Context, + user: discord.User, + *, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.VoiceChannel]] = None, + ): + """ + Unfollow a user. + + Channel is optional, if no channel is provided this will unfollow the user from ALL channels + """ + if not channel: + await ctx.send( + warning(f"**Are you sure? This will remove ALL channels you are following {user.mention} in!** (y/n)") + ) + pred = MessagePredicate.yes_or_no(ctx) + try: + await self.bot.wait_for("message", check=pred, timeout=30) + except asyncio.TimeoutError: + await ctx.send(error("Took too long, cancelling!")) + return + + if pred.result: + await self.unfollow(ctx.author.id, user.id) + else: + await self.unfollow(ctx.author.id, user.id, channel=channel.id) + + await ctx.tick() + + @followers.command(name="follow") + async def followers_follow( + self, + ctx: commands.Context, + user: discord.User, + *, + channel: Union[discord.TextChannel, discord.Thread, discord.VoiceChannel], + ): + """ + Follow a user in a text or voice channel + + For voice channels, its best to use the channel's ID + + If in DMs, it is easier to use the user's ID and the channel's ID + **Make sure to turn on allow DMs from me so I can notify you!** + """ + blocked = await self.config.user(user).blocked() + opt_out = await self.config.user(user).opt_out() + + if opt_out or ctx.author.id in blocked: + await ctx.send( + error( + "Sorry, you cannot follow this user because they blocked you or have turn off follower (opted-out)." + ) + ) + return + + if user.id == ctx.author.id: + await ctx.send(error("Sorry, you can't follow yourself!")) + return + + member = channel.guild.get_member(ctx.author.id) + if not member: + await ctx.send( + error( + f"You don't appear to be in the server {channel.guild.name}\n\nIf this is a mistake, contact the bot owner." + ) + ) + return + + perms = channel.permissions_for(member) + if not perms.read_messages: + await ctx.send(error("You don't have access to that channel!")) + return + + async with self.config.user_from_id(ctx.author.id).following() as following: + if not following.get(str(channel.id), None): + following[str(channel.id)] = [] + + following[str(channel.id)].append(user.id) + + async with self.config.user(user).followers() as followers: + if not followers.get(str(channel.id), None): + followers[str(channel.id)] = [] + + followers[str(channel.id)].append(ctx.author.id) + + try: + await user.send( + f"**__Follower:__**\n**{ctx.author.mention} has followed you in {channel.mention if type(channel) in [discord.TextChannel, discord.VoiceChannel, discord.Thread] else inline(channel.name)} on the server `{channel.guild.name}`**!\n\nIf you want this user to stop following you, block them using `{ctx.clean_prefix}follower block {ctx.author.id}`\n\nYou can also opt-out to stop anyone from following you using `{ctx.clean_prefix}follower opt-out on`\nYou can view your followers using `{ctx.clean_prefix}follower list followers`" + ) + except discord.HTTPException: + # cant notify user, so pass + pass + + await ctx.tick() + + @commands.Cog.listener() + async def on_message(self, message): + if await self.bot.cog_disabled_in_guild(self, message.guild): + return + + guild = message.guild + channel = message.channel + author = message.author + + user_followers = await self.config.user_from_id(author.id).followers() + # check to see if anyone is following this user in the channel + if not user_followers.get(str(channel.id), None): + return + + # check to see last active time is within threshold + last_active_time = (await self.config.user_from_id(author.id).last_active_time()).get(str(channel.id), 0) + now = time.time() + + # update new last active time + async with self.config.user_from_id(author.id).last_active_time() as l: + l[str(channel.id)] = now + + if not now > last_active_time + INACTIVITY_DELAY: + # within inactivity threshold + return + + # notify followers of message + for follower in user_followers[str(channel.id)]: + user = await self.get_user(follower) + # need to make sure the follower is in the same guild + # if not, no need to notify. instead unfollow them automatically + member = guild.get_member(follower) + if not user: + # afaik this should never happen with the API fetch user + continue + + if not member: + # clean member since they aren't in the same guild anymore + await self.unfollow(follower, author.id, channel=channel.id) + continue + + # make sure they have access to the channel + perms = channel.permissions_for(member) + if not perms.read_messages: + # if they no longer have access, silently unfollow them + await self.unfollow(follower, author.id, channel=channel.id) + continue + + try: + # i send to user object since i think this will work so long as + # in one of the shared guilds the user has dm from server members + # turned on, when used in multiple guilds, compared to sending to + # member object of a specific guild + preview = message.content[:200] if message.content else "*No preview available*" + await user.send( + f"**__Follower:__**\n**{author.mention} sent a message in {channel.mention} on the server `{guild.name}`**\n\n**Message Preview:**\n{preview}\n\n{message.jump_url}" + ) + except discord.HTTPException: + # couldn't dm user, pass + pass + + @commands.Cog.listener() + async def on_voice_state_update(self, member, before, after): + if (await self.bot.cog_disabled_in_guild(self, member.guild)) or not after.channel: + return + + guild = member.guild + channel = after.channel + + user_followers = await self.config.user_from_id(member.id).followers() + # check to see if anyone is following this user in the channel + if not user_followers.get(str(channel.id), None): + return + + # check to see last active time is within threshold + last_active_time = (await self.config.user_from_id(member.id).last_active_time()).get(str(channel.id), 0) + now = time.time() + + # update new last active time + async with self.config.user_from_id(member.id).last_active_time() as l: + l[str(channel.id)] = now + + if not now > last_active_time + INACTIVITY_DELAY: + # within inactivity threshold + return + + # notify followers of message + for follower in user_followers[str(channel.id)]: + user = await self.get_user(follower) + # need to make sure the follower is in the same guild + # if not, no need to notify. instead unfollow them automatically + member = guild.get_member(follower) + if not user: + # afaik this should never happen with the API fetch user + continue + + if not member: + # clean member since they aren't in the same guild anymore + await self.unfollow(follower, author.id, channel=channel.id) + continue + + # make sure they have access to the channel + perms = channel.permissions_for(member) + if not perms.read_messages: + # if they no longer have access, silently unfollow them + await self.unfollow(follower, author.id, channel=channel.id) + continue + + try: + await user.send( + f"**__Follower:__**\n**{member.mention} joined the VC `{channel.name}` on the server `{guild.name}`**" + ) + except discord.HTTPException: + # couldn't dm user, pass + pass diff --git a/follower/info.json b/follower/info.json new file mode 100644 index 0000000..e2a8c87 --- /dev/null +++ b/follower/info.json @@ -0,0 +1,17 @@ +{ + "author": [ + "Brandons209" + ], + "name": "Follower", + "disabled": false, + "short": "Allow users to follow other users and get notified when they talk.", + "description": "Similar to Twitter followers, this cog allows a user to follow another user, which will notify them when the followed user talks in a certain channel (after a period of inactivity, 1 hour). Users can opt out, view their followers, and block other users from following them. Followers are tracked through multiple servers, so one person can be followed in multiple channels spanning many servers.", + "tags": [ + "follower", + "twitter", + "watch" + ], + "hidden": false, + "min_bot_version": "3.5.0", + "end_user_data_statement": "This cog does stores user's followers, who they are following, and opt in/out status." +} \ No newline at end of file diff --git a/mayhemmaker/mayhemmaker.py b/mayhemmaker/mayhemmaker.py index 1125b74..d7fbb20 100644 --- a/mayhemmaker/mayhemmaker.py +++ b/mayhemmaker/mayhemmaker.py @@ -335,13 +335,17 @@ async def apply_mayhem( if action == "name" and isinstance(mayhem_item, str): if len(mayhem_item) > 32 or len(mayhem_item) < 2: await ctx.send( - error("Nickname must be 2 to 32 characters in length!"), delete_after=30, reference=ctx.message + error("Nickname must be 2 to 32 characters in length!"), + delete_after=30, + reference=ctx.message, ) return data["old_nick"] = member.nick if member.nick is not None else NO_NICKNAME data["new_nick"] = mayhem_item + old_name = member.display_name + result = await self.apply_action(member, action, mayhem_item) if not result: await ctx.send( @@ -356,9 +360,7 @@ async def apply_mayhem( async with self.config.guild(ctx.guild).current_changes() as current_changes: current_changes[action][str(member.id)] = data - await ctx.send( - f"`{member.display_name}'s` nickname changed to `{mayhem_item}` until ." - ) + await ctx.send(f"`{old_name}`'s nickname changed to `{mayhem_item}` until .") try: await member.send( diff --git a/moreadmin/moreadmin.py b/moreadmin/moreadmin.py index 7d932ad..4870557 100644 --- a/moreadmin/moreadmin.py +++ b/moreadmin/moreadmin.py @@ -759,8 +759,6 @@ async def edit( spoiler=a.is_spoiler(), ) ) - if not files: - files = None try: await message.edit(content=msg, attachments=files, allowed_mentions=discord.AllowedMentions.all()) @@ -796,8 +794,7 @@ async def send( spoiler=a.is_spoiler(), ) ) - if not files: - files = None + try: await channel.send(content=msg, files=files, allowed_mentions=discord.AllowedMentions.all()) await ctx.tick()