diff --git a/README.md b/README.md index 6a5e14c..517b804 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ The required scopes are: And the required permissions: - Embed Links - Manage Messages +- Manage Webhooks - Read Message History - Send Messages - Send Messages in Threads diff --git a/cogs/link_fix.py b/cogs/link_fix.py index ebdc5a8..d5bf6de 100644 --- a/cogs/link_fix.py +++ b/cogs/link_fix.py @@ -136,11 +136,17 @@ async def fix_embeds( or (isinstance(channel, discore.Thread) and (channel.locked or channel.archived))): return - async with Typing(channel): + async def render_and_send() -> tuple[list[tuple[str, list[WebsiteLink]]], dict[discore.Message, list[WebsiteLink]]]: rendered_links = [link for link in links if await link.render()] if not rendered_links: - return - not_sent, messages = await send_fixed_links(rendered_links, guild, original_message) + return [], {} + return await send_fixed_links(rendered_links, guild, original_message) + + if guild.reply_as_original_author_replica: + not_sent, messages = await render_and_send() + else: + async with Typing(channel): + not_sent, messages = await render_and_send() to_delete = [] if messages: @@ -200,13 +206,17 @@ async def send_fixed_links( links_failed: list[tuple[str, list[WebsiteLink]]] = [] grouped = group_items(rendered_links, 2000) + use_original_author_replica = guild.reply_as_original_author_replica + webhook = await get_or_create_webhook(original_message.channel) if use_original_author_replica else None for i, (message_content, links_in_group) in enumerate(grouped): - if i == 0 and guild.reply_to_message: + if webhook is not None: + coro = webhook_send(webhook, original_message, message_content, guild.reply_silently) + elif i == 0 and guild.reply_to_message: coro = discore.fallback_reply(original_message, message_content, silent=guild.reply_silently) else: coro = original_message.channel.send(message_content, silent=guild.reply_silently) - + sent, msg = await safe_send_coro(coro, invalid_form_body='Embed size exceeds maximum size', forbidden=True) if sent and msg: messages_sent[msg] = links_in_group @@ -216,6 +226,66 @@ async def send_fixed_links( return links_failed, messages_sent +async def get_or_create_webhook(channel: GuildMessageableChannel) -> discore.Webhook | None: + """ + Get or create the webhook used to send messages as the original author. + + :param channel: the channel to send the fixed links to + :return: the webhook to use, if available + """ + + webhook_channel = channel.parent if isinstance(channel, discore.Thread) else channel + if webhook_channel is None: + return None + + if not hasattr(webhook_channel, 'webhooks') or not hasattr(webhook_channel, 'create_webhook'): + return None + + if not webhook_channel.permissions_for(channel.guild.me).manage_webhooks: + return None + + success, webhooks = await safe_send_coro(webhook_channel.webhooks(), forbidden=True) + if not success: + return None + bot = discore.Bot.get() + webhook = next(( + w for w in webhooks + if getattr(w.user, 'id', None) == bot.user.id + ), None) + if webhook is not None: + return webhook + success, webhook = await safe_send_coro(webhook_channel.create_webhook(name=bot.user.display_name), forbidden=True) + return webhook if success else None + + +async def webhook_send( + webhook: discore.Webhook, + original_message: discore.Message, + content: str, + silent: bool +) -> discore.Message: + """ + Send a fixed link using the original author's display name and avatar. + + :param webhook: the webhook to use + :param original_message: the message associated with the context to reply to + :param content: the content to send + :param silent: whether to send the message silently + :return: the message created by the webhook + """ + + kwargs = { + 'content': content, + 'username': original_message.author.display_name, + 'avatar_url': original_message.author.display_avatar.url, + 'silent': silent, + 'wait': True + } + if isinstance(original_message.channel, discore.Thread): + kwargs['thread'] = original_message.channel + return await webhook.send(**kwargs) + + async def wait_for_embed(message: discore.Message) -> bool: """ Wait for the message to have embeds. diff --git a/database/migrations/2026_05_26_205445_add_original_author_replica_replies.py b/database/migrations/2026_05_26_205445_add_original_author_replica_replies.py new file mode 100644 index 0000000..b1b217d --- /dev/null +++ b/database/migrations/2026_05_26_205445_add_original_author_replica_replies.py @@ -0,0 +1,19 @@ +"""AddOriginalAuthorReplicaReplies Migration.""" + +from masoniteorm.migrations import Migration + + +class AddOriginalAuthorReplicaReplies(Migration): + def up(self): + """ + Run the migrations. + """ + with self.schema.table("guilds") as table: + table.boolean("reply_as_original_author_replica").after("reply_silently").default(False) + + def down(self): + """ + Revert the migrations. + """ + with self.schema.table("guilds") as table: + table.drop_column("reply_as_original_author_replica") diff --git a/database/models/Guild.py b/database/models/Guild.py index 5d26460..21ce797 100644 --- a/database/models/Guild.py +++ b/database/models/Guild.py @@ -61,6 +61,7 @@ class Guild(DiscordRepresentation): 'roles_use_any_rule': bool, 'reply_to_message': bool, 'reply_silently': bool, + 'reply_as_original_author_replica': bool, 'webhooks': bool, 'original_message': OriginalMessage, 'twitter_view': FxEmbedView, diff --git a/locales/en-US.yml b/locales/en-US.yml index 19206cf..0d317f8 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -56,6 +56,9 @@ settings: read_message_history: "true": "๐ŸŸข `Read message history` permission" "false": "๐Ÿ”ด Missing `read message history` permission" + manage_webhooks: + "true": "๐ŸŸข `Manage webhooks` permission" + "false": "๐Ÿ”ด Missing `manage webhooks` permission" filters: button: toggle: @@ -148,7 +151,7 @@ settings: reply_method: name: "Reply method" description: "Change the behavior on the reply" - content: "**Change what to do on the reply**\n- %{state}\n- %{silent}%{perms}" + content: "**Change what to do on the reply**\n- %{state}\n- %{silent}\n- %{replica}%{perms}" reply: button: "true": "Replying" @@ -163,6 +166,13 @@ settings: state: "true": "๐Ÿ”• Send silently" "false": "๐Ÿ”” Send with a notification" + original_author_replica: + button: + "true": "Send as original author replica" + "false": "Send as %{bot}" + state: + "true": "๐Ÿชช Send as original author replica" + "false": "๐Ÿค– Send as %{bot}" webhooks: name: "Webhooks" description: "Enable/Disable for webhooks" diff --git a/locales/ko.yml b/locales/ko.yml index b6e4f6b..6c7944e 100644 --- a/locales/ko.yml +++ b/locales/ko.yml @@ -53,6 +53,9 @@ settings: read_message_history: "true": "๐ŸŸข `๋ฉ”์‹œ์ง€ ๊ธฐ๋ก ๋ณด๊ธฐ` ๊ถŒํ•œ" "false": "๐Ÿ”ด `๋ฉ”์‹œ์ง€ ๊ธฐ๋ก ๋ณด๊ธฐ` ๊ถŒํ•œ ์—†์Œ" + manage_webhooks: + "true": "๐ŸŸข `์›นํ›„ํฌ ๊ด€๋ฆฌํ•˜๊ธฐ` ๊ถŒํ•œ" + "false": "๐Ÿ”ด `์›นํ›„ํฌ ๊ด€๋ฆฌํ•˜๊ธฐ` ๊ถŒํ•œ ์—†์Œ" filters: button: toggle: @@ -145,7 +148,7 @@ settings: reply_method: name: "๋‹ต์žฅ ๋ฐฉ๋ฒ•" description: "๋‹ต์žฅํ•  ๋•Œ์˜ ๋™์ž‘ ๋ณ€๊ฒฝํ•˜๊ธฐ" - content: "**๋‹ต์žฅํ•  ๋•Œ ์ˆ˜ํ–‰ํ•  ์ž‘์—… ๋ณ€๊ฒฝํ•˜๊ธฐ**\n- %{state}\n- %{silent}%{perms}" + content: "**๋‹ต์žฅํ•  ๋•Œ ์ˆ˜ํ–‰ํ•  ์ž‘์—… ๋ณ€๊ฒฝํ•˜๊ธฐ**\n- %{state}\n- %{silent}\n- %{replica}%{perms}" reply: button: "true": "๋‹ต์žฅ" @@ -160,6 +163,13 @@ settings: state: "true": "๐Ÿ”• ์กฐ์šฉํžˆ ๋ณด๋‚ด๊ธฐ" "false": "๐Ÿ”” ์•Œ๋ฆผ๊ณผ ํ•จ๊ป˜ ๋ณด๋‚ด๊ธฐ" + original_author_replica: + button: + "true": "์›๋ณธ ์ž‘์„ฑ์ž ๋ณต์ œ๋ณธ์œผ๋กœ ๋ณด๋‚ด๊ธฐ" + "false": "%{bot}(์œผ)๋กœ ๋ณด๋‚ด๊ธฐ" + state: + "true": "๐Ÿชช ์›๋ณธ ์ž‘์„ฑ์ž ๋ณต์ œ๋ณธ์œผ๋กœ ๋ณด๋‚ด๊ธฐ" + "false": "๐Ÿค– %{bot}(์œผ)๋กœ ๋ณด๋‚ด๊ธฐ" webhooks: name: "์›นํ›„ํฌ" description: "์›นํ›„ํฌ ํ™œ์„ฑํ™”/๋น„ํ™œ์„ฑํ™”ํ•˜๊ธฐ" diff --git a/src/settings.py b/src/settings.py index e8778d6..6104f27 100644 --- a/src/settings.py +++ b/src/settings.py @@ -403,6 +403,8 @@ async def embed(self) -> discore.Embed: perms.append('manage_messages') if self.ctx.guild.reply_to_message: perms.append('read_message_history') + if self.ctx.guild.reply_as_original_author_replica: + perms.append('manage_webhooks') embed.add_field( name=t('settings.troubleshooting.permissions', channel=self.ctx.channel.mention), value=format_perms(perms, self.ctx.channel.discord_object, include_label=False, include_valid=True), @@ -1123,7 +1125,8 @@ class ReplyMethodSetting(BaseSetting): def __init__(self, interaction: discore.Interaction, view: SettingsView, ctx: DataElements): super().__init__(interaction, view, ctx) - self.reply_to_message = bool(ctx.guild.reply_to_message) + self.reply_as_original_author_replica = bool(ctx.guild.reply_as_original_author_replica) + self.reply_to_message = bool(ctx.guild.reply_to_message and not self.reply_as_original_author_replica) self.reply_silently = bool(ctx.guild.reply_silently) @property @@ -1137,12 +1140,17 @@ async def embed(self) -> discore.Embed: perms.append('send_messages_in_threads') if self.reply_to_message: perms.append('read_message_history') + if self.reply_as_original_author_replica: + perms.append('manage_webhooks') embed = discore.Embed( title=f"{self.emoji} {t(self.name)}", description=t( 'settings.reply_method.content', state=t(f'settings.reply_method.reply.state.{l(self.reply_to_message)}', emoji=self.emoji), silent=t(f'settings.reply_method.silent.state.{l(self.reply_silently)}'), + replica=t( + f'settings.reply_method.original_author_replica.state.{l(self.reply_as_original_author_replica)}', + bot=self.bot.user.display_name), perms=format_perms(perms, self.ctx.channel.discord_object)) ) discore.set_embed_footer(self.bot, embed) @@ -1150,8 +1158,13 @@ async def embed(self) -> discore.Embed: @property async def option(self) -> discore.SelectOption: + has_missing_perms = ( + (self.reply_to_message and is_missing_perm(['read_message_history'], self.ctx.channel.discord_object)) + or (self.reply_as_original_author_replica + and is_missing_perm(['manage_webhooks'], self.ctx.channel.discord_object)) + ) return discore.SelectOption( - label=('โš ๏ธ ' if self.reply_to_message and is_missing_perm(['read_message_history'], self.ctx.channel.discord_object) else '') + label=('โš ๏ธ ' if has_missing_perms else '') + t(self.name), value=self.id, description=t(self.description), @@ -1163,7 +1176,8 @@ async def items(self) -> List[discore.ui.Item]: reply_to_message_button = discore.ui.Button( style=discore.ButtonStyle.primary if self.reply_to_message else discore.ButtonStyle.secondary, label=t(f'settings.reply_method.reply.button.{l(self.reply_to_message)}'), - custom_id=self.id + custom_id=self.id, + disabled=self.reply_as_original_author_replica ) edit_callback(reply_to_message_button, self.view, self.toggle_reply_to_message) reply_silently_button = discore.ui.Button( @@ -1172,9 +1186,19 @@ async def items(self) -> List[discore.ui.Item]: custom_id='reply_silently' ) edit_callback(reply_silently_button, self.view, self.toggle_reply_silently) - return [reply_to_message_button, reply_silently_button] + original_author_replica_button = discore.ui.Button( + style=discore.ButtonStyle.primary if self.reply_as_original_author_replica else discore.ButtonStyle.secondary, + label=t( + f'settings.reply_method.original_author_replica.button.{l(self.reply_as_original_author_replica)}', + bot=self.bot.user.display_name), + custom_id='reply_as_original_author_replica' + ) + edit_callback(original_author_replica_button, self.view, self.toggle_reply_as_original_author_replica) + return [reply_to_message_button, reply_silently_button, original_author_replica_button] async def toggle_reply_to_message(self, view: SettingsView, interaction: discore.Interaction, _) -> None: + if self.reply_as_original_author_replica: + return self.reply_to_message = not self.reply_to_message self.ctx.guild.update({'reply_to_message': self.reply_to_message}) await view.refresh(interaction) @@ -1184,6 +1208,16 @@ async def toggle_reply_silently(self, view: SettingsView, interaction: discore.I self.ctx.guild.update({'reply_silently': self.reply_silently}) await view.refresh(interaction) + async def toggle_reply_as_original_author_replica(self, view: SettingsView, interaction: discore.Interaction, _) -> None: + self.reply_as_original_author_replica = not self.reply_as_original_author_replica + if self.reply_as_original_author_replica: + self.reply_to_message = False + self.ctx.guild.update({ + 'reply_as_original_author_replica': self.reply_as_original_author_replica, + 'reply_to_message': self.reply_to_message + }) + await view.refresh(interaction) + class WebhooksSetting(BaseSetting): """Represents the webhooks setting (respond to webhooks or not)"""