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)