Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Message (anti-spam) constraints #594

Merged
merged 3 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ v4.1.0

- Added optional case-sensitive matching to :class:`daf.logic.contains` and :class:`daf.logic.regex`. The default
is still case-insensitive.
- Added ``constraints`` parameter to :class:`daf.message.TextMESSAGE`. See :ref:`Message constraints` for more
information.
- Fixed SQL log removal through the GUI.
- Fixed CSV and JSON reading through remote.

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 17 additions & 6 deletions docs/source/guide/core/shilldefine.rst
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ We will not cover :class:`daf.guild.USER` separately as the definition process i
the same.
We will also not cover :class:`daf.guild.AutoGUILD` here, as it is covered in :ref:`Automatic Generation (core)`.

Let's define our :class:`daf.guild.GUILD` object now. Its most important parameters are:
Let's define our :class:`daf.guild.GUILD` object now. Its most important (but not all) parameters are:

- ``snowflake``: An integer parameter. Represents a unique identifier, which identifies every Discord resource.
Snowflake can be obtained by
Expand Down Expand Up @@ -146,6 +146,8 @@ Let's expand our example from :ref:`Definition of accounts (core)`.
daf.run(accounts=accounts)




Now let's define our messages.

--------------------------------------
Expand Down Expand Up @@ -178,13 +180,20 @@ The most important parameters inside :class:`daf.message.TextMESSAGE` are:
multiple specified days at a specific time.
- :class:`~daf.message.messageperiod.DailyPeriod`: A period that sends every day at a specific time.

- ``constraints``: A list of constraints that only allow a message to be sent when they are fulfilled. This can for
example be used to only send messages to channels when the last message in that channel is not or own, thus
**preventing unnecessary spam**. Currently a single constraint is supported:

- :class:`daf.message.constraints.AntiSpamMessageConstraint`

Now that we have an overview of the most important parameters, let's define our message.
We will define a message that sends fixed data into a single channel, with a fixed time (duration) period.

.. code-block:: python
:linenos:
:emphasize-lines: 1-3, 19-23
:emphasize-lines: 1-4, 19-24

from daf.message.constraints import AntiSpamMessageConstraint
from daf.message.messageperiod import FixedDurationPeriod
from daf.messagedata import TextMessageData
from daf.message import TextMESSAGE
Expand All @@ -201,12 +210,13 @@ We will define a message that sends fixed data into a single channel, with a fix
is_user=False, # Above token is user account's token and not a bot token.
servers=[
GUILD(
snowflake=863071397207212052,
snowflake=2312312312312312313123,
messages=[
TextMESSAGE(
data=TextMessageData(content="Looking for NFT?"),
channels=[1159224699830677685],
period=FixedDurationPeriod(duration=timedelta(seconds=15))
channels=[3215125123123123123123],
period=FixedDurationPeriod(duration=timedelta(seconds=5)),
constraints=[AntiSpamMessageConstraint(per_channel=True)]
)
]
)
Expand All @@ -217,6 +227,7 @@ We will define a message that sends fixed data into a single channel, with a fix
daf.run(accounts=accounts)



.. image:: ./images/message_definition_example_output.png
:width: 20cm

Expand All @@ -233,7 +244,7 @@ Additionally, it contains a ``volume`` parameter.
Message advertisement examples
--------------------------------------

The following examples show a complete core script setup needed to advertise periodic messages.
The following examples show a complete core script (without message constraints) setup needed to advertise periodic messages.

.. dropdown:: TextMESSAGE

Expand Down
21 changes: 4 additions & 17 deletions src/daf/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def __init__(
*args,
operands: List[BaseLogic] = [],
) -> None:
self.operands = [*operands, *args]
self.operands: List[BaseLogic] = [*operands, *args]


@doc_category("Text matching (logic)")
Expand All @@ -60,12 +60,7 @@ class and_(BooleanLogic):

def check(self, input: str):
for op in self.operands:
if isinstance(op, BaseLogic):
check = op.check(input)
else:
check = op in input

if not check:
if not op.check(input):
return False

return True
Expand All @@ -83,12 +78,7 @@ class or_(BooleanLogic):
"""
def check(self, input: str):
for op in self.operands:
if isinstance(op, BaseLogic):
check = op.check(input)
else:
check = op in input

if check:
if op.check(input):
return True

return False
Expand All @@ -113,11 +103,8 @@ def operand(self):
return self.operands[0]

def check(self, input: str):
op = self.operands[0]
if isinstance(op, BaseLogic):
return not op.check(input)
return not self.operand.check(input)

return op not in input


@doc_category("Text matching (logic)")
Expand Down
1 change: 1 addition & 0 deletions src/daf/message/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .text_based import *
from .messageperiod import *
from .autochannel import *
from .constraints import *

try:
from .voice_based import *
Expand Down
35 changes: 0 additions & 35 deletions src/daf/message/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,41 +600,6 @@ async def initialize(
self.parent = parent
return await super().initialize(event_ctrl)

@async_util.with_semaphore("update_semaphore")
async def _send(self):
"""
Sends the data into the channels.
"""
# Acquire mutex to prevent update method from writing while sending
data_to_send = await self._data.to_dict()
if self._verify_data(data_to_send): # There is data to be send
errored_channels = []
succeeded_channels = []

# Send to channels
for channel in self.channels:
# Clear previous messages sent to channel if mode is MODE_DELETE_SEND
context = await self._send_channel(channel, **data_to_send)
if context["success"]:
succeeded_channels.append(channel)
else:
errored_channels.append({"channel": channel, "reason": context["reason"]})
action = context["action"]
if action is ChannelErrorAction.SKIP_CHANNELS: # Don't try to send to other channels
break

elif action is ChannelErrorAction.REMOVE_ACCOUNT:
self._event_ctrl.emit(EventID.g_account_expired, self.parent.parent)
break

self._update_state(succeeded_channels, errored_channels)
if errored_channels or succeeded_channels:
return self.generate_log_context(
**data_to_send, succeeded_ch=succeeded_channels, failed_ch=errored_channels
)

return None

async def _on_update(self, _, _init_options: Optional[dict], **kwargs):
await self._close()
if "start_in" not in kwargs:
Expand Down
46 changes: 46 additions & 0 deletions src/daf/message/constraints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""
Implements additional message constraints, that are required to pass
for a message to be sent.

.. versionadded:: 4.1
"""
from __future__ import annotations
from abc import abstractmethod, ABC
from _discord import TextChannel

from .autochannel import AutoCHANNEL
from ..misc import doc_category


__all__ = ("AntiSpamMessageConstraint",)


class BaseMessageConstraint(ABC):
@abstractmethod
def check(self, channels: list[TextChannel]) -> list[TextChannel]:
"""
Checks if the message can be sent based on the configured check.
"""


@doc_category("Message constraints")
class AntiSpamMessageConstraint(BaseMessageConstraint):
"""
Prevents a new message to be sent if the last message in the same channel was
sent by us, thus preventing spam on inactivate channels.

.. versionadded:: 4.1
"""
def __init__(self, per_channel: bool = True) -> None:
self.per_channel = per_channel
super().__init__()

def check(self, channels: list[TextChannel | AutoCHANNEL]) -> list[TextChannel]:
allowed = list(filter(
lambda channel: channel.last_message is None or
channel.last_message.author.id != channel._state.user.id, channels
))
if not self.per_channel and len(allowed) != len(channels): # In global mode, only allow to all channels
return []

return allowed
59 changes: 58 additions & 1 deletion src/daf/message/text_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from ..messagedata.dynamicdata import _DeprecatedDynamic

from .constraints import BaseMessageConstraint
from ..messagedata import BaseTextData, TextMessageData, FILE
from ..logging.tracing import trace, TraceLEVELS
from .messageperiod import *
Expand Down Expand Up @@ -83,12 +84,21 @@ class TextMESSAGE(BaseChannelMessage):
printed to the console instead of message being published to the follower channels.

.. versionadded:: 2.10

period: BaseMessagePeriod
The sending period. See :ref:`Message period` for possible types.
constraints: Optional[List[BaseMessageConstraint]]
List of constraints that prevents a message from being sent unless all of them
are fulfilled. See :ref:`Message constraints` for possible types.

.. versionadded:: 4.1
"""

__slots__ = (
"mode",
"sent_messages",
"auto_publish",
"constraints",
)

_old_data_type = Union[list, tuple, set, str, discord.Embed, FILE, _FunctionBaseCLASS]
Expand All @@ -104,7 +114,8 @@ def __init__(
start_in: Optional[Union[timedelta, datetime]] = None,
remove_after: Optional[Union[int, timedelta, datetime]] = None,
auto_publish: bool = False,
period: BaseMessagePeriod = None
period: BaseMessagePeriod = None,
constraints: List[BaseMessageConstraint] = None
):
if not isinstance(data, BaseTextData):
trace(
Expand Down Expand Up @@ -133,6 +144,11 @@ def __init__(
data = TextMessageData(content, embed, files)

super().__init__(start_period, end_period, data, channels, start_in, remove_after, period)

if constraints is None:
constraints = []

self.constraints = constraints
self.mode = mode
self.auto_publish = auto_publish
# Dictionary for storing last sent message for each channel
Expand Down Expand Up @@ -333,6 +349,45 @@ async def _handle_error(
def _verify_data(self, data: dict) -> bool:
return super()._verify_data(TextMessageData, data)

@async_util.with_semaphore("update_semaphore")
async def _send(self):
"""
Sends the data into the channels.
"""
# Acquire mutex to prevent update method from writing while sending
data_to_send = await self._data.to_dict()
if self._verify_data(data_to_send): # There is data to be send
errored_channels = []
succeeded_channels = []

channels = self.channels
for constraint in self.constraints:
channels = constraint.check(channels)

# Send to channels
for channel in channels:
# Clear previous messages sent to channel if mode is MODE_DELETE_SEND
context = await self._send_channel(channel, **data_to_send)
if context["success"]:
succeeded_channels.append(channel)
else:
errored_channels.append({"channel": channel, "reason": context["reason"]})
action = context["action"]
if action is ChannelErrorAction.SKIP_CHANNELS: # Don't try to send to other channels
break

elif action is ChannelErrorAction.REMOVE_ACCOUNT:
self._event_ctrl.emit(EventID.g_account_expired, self.parent.parent)
break

self._update_state(succeeded_channels, errored_channels)
if errored_channels or succeeded_channels:
return self.generate_log_context(
**data_to_send, succeeded_ch=succeeded_channels, failed_ch=errored_channels
)

return None

async def _send_channel(
self,
channel: Union[discord.TextChannel, discord.Thread, None],
Expand Down Expand Up @@ -461,6 +516,8 @@ class DirectMESSAGE(BaseMESSAGE):
* int - provided amounts of successful sends
* timedelta - the specified time difference
* datetime - specific date & time
period: BaseMessagePeriod
The sending period. See :ref:`Message period` for possible types.
"""

__slots__ = (
Expand Down
39 changes: 37 additions & 2 deletions src/daf/message/voice_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from ..messagedata.dynamicdata import _DeprecatedDynamic

from ..messagedata import BaseVoiceData, VoiceMessageData, FILE
from ..misc import doc, instance_track
from ..misc import doc, instance_track, async_util
from ..logging import sql
from .. import dtypes

Expand Down Expand Up @@ -69,7 +69,7 @@ class VoiceMESSAGE(BaseChannelMessage):
* timedelta - the specified time difference
* datetime - specific date & time
period: BaseMessagePeriod
The sending period.
The sending period. See :ref:`Message period` for possible types.
"""
__slots__ = (
"volume",
Expand Down Expand Up @@ -243,6 +243,41 @@ def initialize(self, parent: Any, event_ctrl: EventController, channel_getter: C
def _verify_data(self, data: dict) -> bool:
return super()._verify_data(VoiceMessageData, data)

@async_util.with_semaphore("update_semaphore")
async def _send(self):
"""
Sends the data into the channels.
"""
# Acquire mutex to prevent update method from writing while sending
data_to_send = await self._data.to_dict()
if self._verify_data(data_to_send): # There is data to be send
errored_channels = []
succeeded_channels = []

# Send to channels
for channel in self.channels:
# Clear previous messages sent to channel if mode is MODE_DELETE_SEND
context = await self._send_channel(channel, **data_to_send)
if context["success"]:
succeeded_channels.append(channel)
else:
errored_channels.append({"channel": channel, "reason": context["reason"]})
action = context["action"]
if action is ChannelErrorAction.SKIP_CHANNELS: # Don't try to send to other channels
break

elif action is ChannelErrorAction.REMOVE_ACCOUNT:
self._event_ctrl.emit(EventID.g_account_expired, self.parent.parent)
break

self._update_state(succeeded_channels, errored_channels)
if errored_channels or succeeded_channels:
return self.generate_log_context(
**data_to_send, succeeded_ch=succeeded_channels, failed_ch=errored_channels
)

return None

async def _send_channel(self,
channel: discord.VoiceChannel,
file: Optional[FILE]) -> dict:
Expand Down