From 529d2f9faa48ad0140a2998f30cf25dac1836ad0 Mon Sep 17 00:00:00 2001 From: Lunaphied Date: Mon, 8 Jul 2024 13:11:13 -0600 Subject: [PATCH] discord: implement force proxied emoji (resolves #11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a new feature to Séance where reference user reactions of emoji can automatically be proxied. Any emoji provided by the `proxied-emoji` config value will be proxied. This removes the ability of the reference user to react with those emoji and is primarily assumed to be used for things like custom heart reactions that are unique per-user, making their usage easier. The valid config value is a comma or whitespace separated list of unicode emoji and Discord custom emoji IDs that should be handled this way. `*` may also be used to indicate that *all* reactions by the reference user should be proxied. This commit also revises the README to more clearly list the available configuration options. --- README.md | 18 ++++++ seance/discord_bot/__init__.py | 101 ++++++++++++++++++++++++--------- 2 files changed, 92 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 4748574..6d29eb0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,23 @@ $ seance-discord --token ODDFOFXUpgf7yEntul5ockCA.OFk6Ph.lmsA54bT0Fux1IpsYvey5Xu Note that the Discord bot also requires the Presence and Server Members Privileged Gateway Intents, which can be enabled in the "Bot" settings of the Discord application page. +### Options + +These are the available configuration options, configured as described [below](#config-file). + +#### Discord +- `token` - The Discord bot token used to authenticate, **important**: this must be kept secret as it allows anyone to control your bot account. +- `ref-user-id` - The reference user's Discord ID. This is the user account allowed to proxy messages and execute Séance commands. +- `pattern` - The regular expression pattern used to match a message to be proxied. Must contain a group named `content` which should contain the message body that will be in the proxied message. +- `prefix` - A prefix that can be used before a `!` command (such as `!edit`) to uniquely indicate that this instance of Séance should handle the command. Unprefixed commands are always accepted, even when this is set. +- `proxied-emoji` - A whitespace or comma separated list of unicode emoji (`🤝`) and Discord custom emoji ID numbers that will *always* be reproxied by Séance when used as a reaction by the reference user. The reference user will *not* be able to react with this emoji themselves, it will always be removed. + +- **TODO: DM Mode configuration** + + +### Telegram +**TODO** + ### Config File Anything that can be passed as a command-line option can also be specified an INI config file. Options for the Discord bot are placed under a `[Discord]` section, with the name of the INI key being the same as the command-line option without the leading `--`. Words can be separated by dashes, underscores, or spaces. For example, `--ref-user-id 188344527881400991` can be any of the following: @@ -80,6 +97,7 @@ Pros of Séance over PluralKit on Discord: - No webhooks; real Discord account - Role colors in name - Real replies +- Easily proxy emoji reactions Cons of Séance over PluralKit on Discord: - Requires self-hosting diff --git a/seance/discord_bot/__init__.py b/seance/discord_bot/__init__.py index 2b241fc..e6038ed 100644 --- a/seance/discord_bot/__init__.py +++ b/seance/discord_bot/__init__.py @@ -3,23 +3,18 @@ import os import re import sys -import json -import asyncio -import argparse from io import StringIO from typing import Union, Optional import discord -from discord import Message, Member, Status, ChannelType +from discord import Message, Member, PartialEmoji, Status, ChannelType +from discord.raw_models import RawReactionActionEvent from discord import Emoji -from discord.activity import Activity, ActivityType +from discord.activity import Activity +from discord.enums import ActivityType from discord.errors import HTTPException from discord.message import PartialMessage -# import discord_slash -# from discord_slash.utils.manage_commands import create_option -# from discord_slash.model import SlashCommandOptionType - import PythonSed from PythonSed import Sed @@ -57,10 +52,12 @@ def running_in_systemd() -> bool: class SeanceClient(discord.Client): def __init__(self, ref_user_id, pattern, command_prefix, *args, dm_guild_id=None, dm_manager_options=None, - sdnotify=False, default_status=False, default_presence=False, forward_pings=None, **kwargs + sdnotify=False, default_status=False, default_presence=False, forward_pings=None, proxied_emoji=[], + **kwargs ): self.ref_user_id = ref_user_id + self.proxied_emoji = proxied_emoji # If we weren't given an already compiled re.Pattern, compile it now. if not isinstance(pattern, re.Pattern): @@ -555,23 +552,15 @@ async def handle_custom_reaction(self, message: Message, content: str): target = await self._get_shortcut_target(message) - group_dict = DISCORD_REACTION_SHORTCUT_PATTERN.fullmatch(content).groupdict() - - # Find the emoji in the client cache. - if emoji := self.get_emoji(int(group_dict["id"])): - payload = emoji - else: - # Fail over to searching the messaage reactions. - for react in target.reactions: - if react.emoji.id == int(group_dict["id"]): - payload = react.emoji - break - # Fail out. - else: - print(f"Custom Emoji ({content[1:]}) out of scope; not directly accessible by bot or present in message reactions.", file=sys.stderr) - return + print(content) + emoji = PartialEmoji.from_str(content[1:]) + print(emoji) + # + # else: + # print(f"Custom Emoji ({content[1:]}) out of scope; not directly accessible by bot or present in message reactions.", file=sys.stderr) + # return - await self._handle_reaction(target, payload, group_dict["action"] == '+') + await self._handle_reaction(target, emoji, content[0] == '+') async def handle_newdm_command(self, accountish): @@ -744,6 +733,61 @@ async def on_presence_update(self, _before: Member, after: Member): self._cached_status = status + # Needs to be raw because message might not be in the message cache. + async def on_raw_reaction_add(self, payload: RawReactionActionEvent): + + # Restrict to only reactions added by our reference user. + if payload.user_id != self.ref_user_id: + return + + # Keep track of if this emoji matched our list of force proxied emoji. + # `*` is usable as a global "proxy any reactions by the reference user". + proxied = '*' in self.proxied_emoji + + # We have to handle default Unicode emoji slightly differently than Discord emoji. + if payload.emoji.id is None: + # This is a Unicode emoji and the actual value is stored in name. + if payload.emoji.id in self.proxied_emoji: + proxied=True + else: + # This is a custom Discord emoji + if str(payload.emoji.id) in self.proxied_emoji: + proxied=True + + # Don't do anything further if this reaction shouldn't be force proxied. + if not proxied: + return + + # Try to add the given emoji and clear the other, if the add fails this will prevent clearing the existing + # reaction so you still get the reaction. + # NOTE: Due to a quirk of how Discord/discord.py works, if another user has already added a given emoji + # and that emoji is *still* on the given message when we add the reaction, we don't have to actually + # have that emoji ourselves, this means that force proxied emoji *always* work. + channel = self.get_channel(payload.channel_id) + message = channel.get_partial_message(payload.message_id) + try: + await message.add_reaction(payload.emoji) + await message.remove_reaction(payload.emoji, member=payload.member) + except HTTPException: + print('An error occurred while trying to reproxy a force proxied emoji', file=sys.stdout) + + +def _split_proxied_emoji(s: str) -> set[str]: + """Split our list of proxied emoji and cleanup the result to produce a set of emoji IDs and Unicode emoji..""" + + emoji = set() + + # Split by non-alphanum and commas. + for item in re.split(r'\s+|,', s): + if not len(item): + # Skip blank entries. + continue + + # Append to our set of items. + emoji.add(item) + + return emoji + def main(): options = [ @@ -774,6 +818,9 @@ def main(): ConfigOption(name='Forward pings', required=False, default=False, type=bool, help="Whether to message the proxied user upon the bot getting pinged", ), + ConfigOption(name='proxied emoji', required=False, default=set(), + help="Comma separated list of emoji or emoji IDs to always proxy when used as a reaction by the reference user." + ), ] sdnotify_available = 'sdnotify' in sys.modules @@ -792,7 +839,6 @@ def main(): config_handler = ConfigHandler(options, env_var_prefix='SEANCE_DISCORD_', config_section='Discord', argparse_init_kwargs={ 'prog': 'seance-discord', 'epilog': help_epilog }, ) - options = config_handler.parse_all_sources() try: @@ -831,6 +877,7 @@ def main(): default_presence = options.default_presence, forward_pings=options.forward_pings, intents=intents, + proxied_emoji=_split_proxied_emoji(options.proxied_emoji), ) print("Starting Séance Discord bot.") client.run(options.token)