Skip to content

Commit

Permalink
Merge pull request #256 from davidhozic/bug/252_direct_message
Browse files Browse the repository at this point in the history
Fixes bugs:
- #253 
- #252
  • Loading branch information
davidhozic authored Jan 7, 2023
2 parents 7b372ce + 6a6b330 commit 202d58b
Show file tree
Hide file tree
Showing 9 changed files with 156 additions and 38 deletions.
29 changes: 28 additions & 1 deletion src/daf/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def __init__(self,
connector = ProxyConnector.from_url(proxy)

self._client = discord.Client(intents=intents, connector=connector)
self._deleted = False
misc._write_attr_once(self, "_update_sem", asyncio.Semaphore(2))

def __eq__(self, other):
Expand All @@ -131,6 +132,19 @@ def running(self) -> bool:
"""
return self._running

@property
def deleted(self) -> bool:
"""
Returns
-----------
True
The object is no longer in the framework and should no longer
be used.
False
Object is in the framework in normal operation.
"""
return self._deleted

@property
def servers(self):
"""
Expand All @@ -143,6 +157,16 @@ def servers(self):
def client(self) -> discord.Client:
"Returns the API wrapper client"
return self._client

def _delete(self):
"""
Sets the internal _deleted flag to True,
indicating the object should not be used.
"""
self._deleted = True
for server in self.servers:
server._delete()


async def initialize(self):
"""
Expand Down Expand Up @@ -212,6 +236,7 @@ def remove_server(self, server: Union[guild.GUILD, guild.USER, guild.AutoGUILD])
``server`` is not in the shilling list.
"""
if isinstance(server, guild._BaseGUILD):
server._delete()
self._servers.remove(server)
else:
self._autoguilds.remove(server)
Expand Down Expand Up @@ -242,17 +267,19 @@ def get_server(self, snowflake: Union[int, discord.Guild, discord.User, discord.

return None

async def close(self):
async def _close(self):
"""
Signals the tasks of this account to finish and
waits for them.
"""
trace(f"Logging out of {self.client.user.display_name}...")
self._running = False
self._delete()
for exc in await asyncio.gather(self.loop_task, return_exceptions=True):
if exc is not None:
trace(f"Exception occurred in main task for account {self.client.user.display_name} (Token: {self._token[:TOKEN_MAX_PRINT_LEN]})",
TraceLEVELS.ERROR, exc)

await self._client.close()

async def _loop(self):
Expand Down
11 changes: 9 additions & 2 deletions src/daf/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,11 @@ async def add_object(obj, snowflake=None):

@typechecked
@misc.doc_category("Dynamic mod.")
def remove_object(snowflake: Union[guild._BaseGUILD, message.BaseMESSAGE, guild.AutoGUILD, client.ACCOUNT]) -> None:
async def remove_object(snowflake: Union[guild._BaseGUILD, message.BaseMESSAGE, guild.AutoGUILD, client.ACCOUNT]) -> None:
"""
.. versionchanged:: v2.4.1
Turned async for fix bug of missing functionality
.. versionchanged:: v2.4
| Now accepts client.ACCOUNT.
| Removed support for ``int`` and for API wrapper (PyCord) objects.
Expand Down Expand Up @@ -289,6 +292,10 @@ def remove_object(snowflake: Union[guild._BaseGUILD, message.BaseMESSAGE, guild.
for account in GLOBALS.accounts:
if snowflake in account.servers:
account.remove_server(snowflake)

elif isinstance(snowflake, client.ACCOUNT):
await snowflake._close()
GLOBALS.accounts.remove(snowflake)


@typechecked
Expand Down Expand Up @@ -370,7 +377,7 @@ def _shutdown_clean(loop: asyncio.AbstractEventLoop) -> None:
The loop to stop.
"""
for account in GLOBALS.accounts:
loop.run_until_complete(account.close())
loop.run_until_complete(account._close())


@typechecked
Expand Down
90 changes: 79 additions & 11 deletions src/daf/guild.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ def __init__(self,
def __repr__(self) -> str:
return f"{type(self).__name__}(discord={self._apiobject})"

@property
def deleted(self) -> bool:
"""
Returns
-----------
True
The object is no longer in the framework and should no longer
be used.
False
Object is in the framework in normal operation.
"""
return self._deleted

@property
def messages(self) -> List[BaseMESSAGE]:
"""
Expand Down Expand Up @@ -142,6 +155,8 @@ def _delete(self):
Sets the internal _deleted flag to True.
"""
self._deleted = True
for message in self._messages:
message._delete()

@typechecked
async def add_message(self, message: BaseMESSAGE):
Expand Down Expand Up @@ -183,6 +198,7 @@ def remove_message(self, message: BaseMESSAGE):
ValueError
Raised when the message is not present in the list.
"""
message._delete()
self._messages.remove(message)

async def initialize(self, parent: Any, getter: Callable) -> None:
Expand Down Expand Up @@ -251,14 +267,18 @@ async def update(self, init_options={}, **kwargs):
raise NotImplementedError

@misc._async_safe("update_semaphore", 1)
async def _advertise(self):
async def _advertise(self) -> List[Coroutine]:
"""
Main coroutine responsible for sending all the messages to this specific guild,
Common to all messages, function responsible for sending all the messages to this specific guild,
it is called from the core module's advertiser task.
Returns
-----------
List[Coroutine]
List of coroutines that will call message._send() method.
"""
to_await = []
to_remove = []
guild_ctx = self.generate_log_context()
for message in self._messages:
if message._check_state():
to_remove.append(message)
Expand All @@ -272,13 +292,7 @@ async def _advertise(self):
for message in to_remove:
self.remove_message(message)

# Await coroutines outside the main loop to prevent list modification (by user)
# while iterating, this way even if the user removes the message, it will still be shilled
# but no exceptions will be raised when trying to remove the message.
for coro in to_await:
message_ctx = await coro
if self.logging and message_ctx is not None:
await logging.save_log(guild_ctx, message_ctx)
return to_await

def generate_log_context(self) -> Dict[str, Union[str, int]]:
"""
Expand Down Expand Up @@ -364,6 +378,22 @@ async def initialize(self, parent: Any) -> None:
Raised from .add_message(message_object) method.
"""
return await super().initialize(parent, parent.client.get_guild)

async def _advertise(self) -> None:
"""
Implementation specific _advertise method.
Same as super()._advertise(), except it removes other DirectMESSAGE
instances in case of them got a forbidden request.
"""
to_await = await super()._advertise()
guild_ctx = self.generate_log_context()
# Await coroutines outside the main loop to prevent list modification (by user)
# while iterating, this way even if the user removes the message, it will still be shilled
# but no exceptions will be raised when trying to remove the message.
for coro in to_await:
message_ctx = await coro
if self.logging and message_ctx is not None:
await logging.save_log(guild_ctx, message_ctx)

@misc._async_safe("update_semaphore", 2) # Take 2 since 2 tasks share access
async def update(self, init_options={}, **kwargs):
Expand Down Expand Up @@ -428,6 +458,7 @@ class USER(_BaseGUILD):
"""
__slots__ = (
"update_semaphore",
"_panic"
)

@typechecked
Expand All @@ -437,6 +468,7 @@ def __init__(self,
logging: Optional[bool] = False,
remove_after: Optional[Union[timedelta, datetime]]=None) -> None:
super().__init__(snowflake, messages, logging, remove_after)
self._panic = False # Set to True whenever message sends detected insufficient permissions
misc._write_attr_once(self, "update_semaphore", asyncio.Semaphore(2)) # Only allows re-referencing this attribute once

def _check_state(self) -> bool:
Expand All @@ -450,7 +482,7 @@ def _check_state(self) -> bool:
False
The user is in proper state, do not delete.
"""
return super()._check_state()
return self._panic or super()._check_state()

async def initialize(self, parent: Any):
"""
Expand All @@ -465,6 +497,29 @@ async def initialize(self, parent: Any):
"""
return await super().initialize(parent, parent.client.get_or_fetch_user)


async def _advertise(self) -> None:
"""
Implementation specific _advertise method.
Same as super()._advertise(), except it removes other DirectMESSAGE
instances in case of them got a forbidden request.
"""
to_await = await super()._advertise()
guild_ctx = self.generate_log_context()
# Await coroutines outside the main loop to prevent list modification (by user)
# while iterating, this way even if the user removes the message, it will still be shilled
# but no exceptions will be raised when trying to remove the message.
for coro in to_await:
message_ctx, panic = await coro
if self.logging and message_ctx is not None:
await logging.save_log(guild_ctx, message_ctx)

# panic means that the message send resulted in a forbidden error
# signaling all other messages should be removed without send
if panic:
self._panic = True
break

@misc._async_safe("update_semaphore", 2)
async def update(self, init_options={}, **kwargs):
"""
Expand Down Expand Up @@ -603,6 +658,19 @@ def created_at(self) -> datetime:
Returns the datetime of when the object has been created.
"""
return self._created_at

@property
def deleted(self) -> bool:
"""
Returns
-----------
True
The object is no longer in the framework and should no longer
be used.
False
Object is in the framework in normal operation.
"""
return self._deleted

def _delete(self):
"""
Expand Down
26 changes: 24 additions & 2 deletions src/daf/message/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ class BaseMESSAGE:
"update_semaphore",
"parent",
"remove_after",
"_created_at"
"_created_at",
"_deleted"
)

@typechecked
Expand Down Expand Up @@ -126,11 +127,12 @@ def __init__(self,
self._created_at = datetime.now()
self._data = data
self._fbcdata = isinstance(data, _FunctionBaseCLASS)
self._deleted = False
# Attributes created with this function will not be re-referenced to a different object
# if the function is called again, ensuring safety (.update_method)
misc._write_attr_once(self, "update_semaphore", asyncio.Semaphore(1))
# For comparing copies of the object (prevents .update from overwriting)
misc._write_attr_once(self, "_id", id(self))
misc._write_attr_once(self, "_id", id(self))

def __repr__(self) -> str:
return f"{type(self).__name__}(data={self._data})"
Expand Down Expand Up @@ -174,6 +176,26 @@ def created_at(self) -> datetime:
"Returns the datetime of when the object was created"
return self._created_at

@property
def deleted(self) -> bool:
"""
Returns
-----------
True
The object is no longer in the framework and should no longer
be used.
False
Object is in the framework in normal operation.
"""
return self._deleted

def _delete(self):
"""
Sets the internal _deleted flag to True,
indicating the object should not be used.
"""
self._deleted = True

def _check_state(self) -> bool:
"""
Checks if the message is ready to be deleted.
Expand Down
24 changes: 9 additions & 15 deletions src/daf/message/text_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,15 +716,6 @@ async def _handle_error(self, ex: Exception) -> bool:
if ex.code == 10008: # Unknown message
self.previous_message = None
handled = True
elif ex.status == 403 or ex.code in {50007, 10001, 10003}:
# Not permitted to send messages to that user!
# Remove all messages to prevent an account ban
for m in self.parent.messages:
if m in self.parent.messages:
self.parent.remove_message(m)

if ex.status in {400, 403}: # Bad Request
await asyncio.sleep(RLIM_USER_WAIT_TIME) # To avoid triggering self-bot detection

return handled

Expand Down Expand Up @@ -772,19 +763,22 @@ async def _send(self) -> Union[dict, None]:
Returns
----------
Union[Dict, None]
Returns a dictionary generated by the ``generate_log_context`` method or the None object if message wasn't ready to be sent (:ref:`data_function` returned None or an invalid type)
This is then passed to :ref:`GUILD`._generate_log method.
Tuple[Union[dict, None], bool]
Returns a tuple of logging context and bool
variable that signals the upper layer all other messages should be removed
due to a forbidden error which automatically causes other messages to fail
and increases risk of getting a user account banned.
"""
# Parse data from the data parameter
data_to_send = await self._get_data()
context, panic = None, False
if any(data_to_send.values()):
context = await self._send_channel(**data_to_send)
self._update_state()
return self.generate_log_context(context, **data_to_send)
panic = ("reason" in context and context["reason"].status in {400, 403})
context = self.generate_log_context(context, **data_to_send)

return None
return context, panic

@typechecked
@misc._async_safe("update_semaphore")
Expand Down
4 changes: 2 additions & 2 deletions testing/test_autogen.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async def test_autoguild(guilds, accounts):
assert guild_exclude not in found, "AutoGUILD included the guild that matches exclude pattern."
finally:
if auto_guild is not None:
daf.remove_object(auto_guild)
await daf.remove_object(auto_guild)

@pytest.mark.asyncio
async def test_autochannel(guilds, channels, accounts):
Expand Down Expand Up @@ -77,7 +77,7 @@ async def test_autochannel(guilds, channels, accounts):
await auto_channel2.update()
finally:
if daf_guild is not None:
daf.remove_object(daf_guild)
await daf.remove_object(daf_guild)



Expand Down
Loading

0 comments on commit 202d58b

Please sign in to comment.