From cd9b6b4d00b9b12ece08710ccf8a7d1d2a2ab885 Mon Sep 17 00:00:00 2001 From: Klemek Date: Tue, 18 May 2021 16:04:28 +0200 Subject: [PATCH 01/13] new alias for random --- src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 97dda5b..d8f5fb4 100644 --- a/src/main.py +++ b/src/main.py @@ -81,7 +81,7 @@ bot.register_command( scanners.LastScanner.help(), ) bot.register_command( - "rand(om)?", + "(rand(om)?|mood)", lambda *args: scanners.RandomScanner().compute(*args), "rand: read a random message", scanners.RandomScanner.help(), From 38056f430fecd08740dfe46e5fdb72eebe53b9b0 Mon Sep 17 00:00:00 2001 From: Klemek Date: Tue, 18 May 2021 16:08:38 +0200 Subject: [PATCH 02/13] small fixes --- src/logs/channel_logs.py | 10 ++++++---- src/scanners/emojis_scanner.py | 1 - src/utils/utils.py | 5 ----- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/logs/channel_logs.py b/src/logs/channel_logs.py index a5e3857..9890d7f 100644 --- a/src/logs/channel_logs.py +++ b/src/logs/channel_logs.py @@ -1,10 +1,8 @@ from typing import Union, Tuple, Any import discord -from discord import message from datetime import datetime from . import MessageLog -from utils import FakeMessage CHUNK_SIZE = 2000 FORMAT = 3 @@ -84,7 +82,9 @@ class ChannelLogs: done = 0 async for message in channel.history( limit=CHUNK_SIZE, - before=FakeMessage(self.first_message_id) + before=discord.MessageReference( + self.first_message_id, self.id, guild_id=self.guild.id + ) if self.first_message_id is not None else None, oldest_first=False, @@ -110,7 +110,9 @@ class ChannelLogs: tmp_message_id = self.last_message_id async for message in channel.history( limit=CHUNK_SIZE, - after=FakeMessage(self.last_message_id), + after=discord.MessageReference( + self.first_message_id, self.id, guild_id=self.guild.id + ), oldest_first=True, ): last_message_date = message.created_at diff --git a/src/scanners/emojis_scanner.py b/src/scanners/emojis_scanner.py index c1c0de2..c96498a 100644 --- a/src/scanners/emojis_scanner.py +++ b/src/scanners/emojis_scanner.py @@ -1,5 +1,4 @@ from typing import Dict, List -from collections import defaultdict import discord diff --git a/src/utils/utils.py b/src/utils/utils.py index 4457326..224b471 100644 --- a/src/utils/utils.py +++ b/src/utils/utils.py @@ -84,11 +84,6 @@ def escape_text(text: str) -> str: return discord.utils.escape_markdown(discord.utils.escape_mentions(text)) -class FakeMessage: - def __init__(self, id: int): - self.id = id - - # FILE From a01414dce76c032703df900c8de3dca333a582a6 Mon Sep 17 00:00:00 2001 From: Klemek Date: Tue, 18 May 2021 16:54:18 +0200 Subject: [PATCH 03/13] small improvments --- src/logs/channel_logs.py | 17 +++++------------ src/logs/message_log.py | 16 ++++------------ src/utils/utils.py | 21 ++++++++++++++++++++- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/logs/channel_logs.py b/src/logs/channel_logs.py index 9890d7f..2c4afe8 100644 --- a/src/logs/channel_logs.py +++ b/src/logs/channel_logs.py @@ -3,12 +3,11 @@ import discord from datetime import datetime from . import MessageLog +from utils import serialize, FakeMessage CHUNK_SIZE = 2000 FORMAT = 3 -NOT_SERIALIZED = ["channel", "guild", "start_date"] - class ChannelLogs: def __init__(self, channel: Union[discord.TextChannel, dict], guild: Any): @@ -82,9 +81,7 @@ class ChannelLogs: done = 0 async for message in channel.history( limit=CHUNK_SIZE, - before=discord.MessageReference( - self.first_message_id, self.id, guild_id=self.guild.id - ) + before=FakeMessage(self.first_message_id) if self.first_message_id is not None else None, oldest_first=False, @@ -110,9 +107,7 @@ class ChannelLogs: tmp_message_id = self.last_message_id async for message in channel.history( limit=CHUNK_SIZE, - after=discord.MessageReference( - self.first_message_id, self.id, guild_id=self.guild.id - ), + after=FakeMessage(self.first_message_id), oldest_first=True, ): last_message_date = message.created_at @@ -121,7 +116,7 @@ class ChannelLogs: await m.load(message) self.messages.insert(0, m) yield len(self.messages), False - except discord.errors.HTTPException: + except discord.errors.HTTPException as e: yield -1, True return # When an exception occurs (like Forbidden) self.start_date = ( @@ -130,8 +125,6 @@ class ChannelLogs: yield len(self.messages), True def dict(self) -> dict: - channel = dict(self.__dict__) - for key in NOT_SERIALIZED: - channel.pop(key, None) + channel = serialize(self, not_serialized=["channel", "guild", "start_date"]) channel["messages"] = [message.dict() for message in self.messages] return channel diff --git a/src/logs/message_log.py b/src/logs/message_log.py index 263c245..67e9205 100644 --- a/src/logs/message_log.py +++ b/src/logs/message_log.py @@ -2,13 +2,10 @@ from typing import Union, Any import discord from datetime import datetime -from utils import is_extension +from utils import is_extension, serialize IMAGE_FORMAT = [".gif", ".gifv", ".png", ".jpg", ".jpeg", ".bmp"] -EMBED_IMAGES = ["image", "gifv"] - - -NOT_SERIALIZED = ["channel"] +EMBED_IMAGES = ["image", "gifv", "gif"] class MessageLog: @@ -81,11 +78,6 @@ class MessageLog: self.reactions[str(reaction.emoji)] += [user.id] def dict(self) -> dict: - message = dict(self.__dict__) - for key in NOT_SERIALIZED: - message.pop(key, None) - message["created_at"] = self.created_at.isoformat() - message["edited_at"] = ( - self.edited_at.isoformat() if self.edited_at is not None else None + return serialize( + self, not_serialized=["channel"], dates=["created_at", "edited_at"] ) - return message diff --git a/src/utils/utils.py b/src/utils/utils.py index 224b471..19e158a 100644 --- a/src/utils/utils.py +++ b/src/utils/utils.py @@ -30,7 +30,7 @@ def generate_help( replace_args=[], ): arg_list = "* " + "\n* ".join( - replace_args + COMMON_HELP_ARGS[len(replace_args) :] + args + args + replace_args + COMMON_HELP_ARGS[len(replace_args) :] ) return f"""``` %{cmd}: {info} @@ -84,6 +84,10 @@ def escape_text(text: str) -> str: return discord.utils.escape_markdown(discord.utils.escape_mentions(text)) +class FakeMessage: + def __init__(self, id: int): + self.id = id + # FILE @@ -132,6 +136,21 @@ def val_sum(d: Dict[Any, int]) -> int: return sum(d.values()) +def serialize( + obj: Any, *, not_serialized: List[str] = [], dates: List[str] = [] +) -> Dict: + output = dict(obj.__dict__) + for key in not_serialized: + output.pop(key, None) + for key in dates: + if output[key]: + try: + output[key] = getattr(obj, key).isoformat() + except AttributeError: + pass + return output + + # MESSAGE FORMATTING From b2858cca95a2a4d9322e0280601a8e75262ab37e Mon Sep 17 00:00:00 2001 From: Klemek Date: Tue, 18 May 2021 18:13:37 +0200 Subject: [PATCH 04/13] nsfw filters --- src/logs/channel_logs.py | 14 +++++++++++--- src/logs/guild_logs.py | 1 + src/scanners/scanner.py | 23 ++++++++++++++++++++++- src/utils/utils.py | 8 ++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/logs/channel_logs.py b/src/logs/channel_logs.py index 2c4afe8..65d45c2 100644 --- a/src/logs/channel_logs.py +++ b/src/logs/channel_logs.py @@ -47,11 +47,17 @@ class ChannelLogs: def is_format(self): return self.format == FORMAT + def preload(self, channel: discord.TextChannel): + self.name = channel.name + self.channel = channel + + @property + def nsfw(self): + self.channel.nsfw + async def load( self, channel: discord.TextChannel, start_date: datetime, stop_date: datetime ) -> Tuple[int, int]: - self.name = channel.name - self.channel = channel is_empty = self.last_message_id is None try: if is_empty: @@ -125,6 +131,8 @@ class ChannelLogs: yield len(self.messages), True def dict(self) -> dict: - channel = serialize(self, not_serialized=["channel", "guild", "start_date"]) + channel = serialize( + self, not_serialized=["channel", "guild", "start_date"] + ) channel["messages"] = [message.dict() for message in self.messages] return channel diff --git a/src/logs/guild_logs.py b/src/logs/guild_logs.py index 7600077..8e84b23 100644 --- a/src/logs/guild_logs.py +++ b/src/logs/guild_logs.py @@ -231,6 +231,7 @@ class GuildLogs: if channel.id not in self.channels or fresh: loading_new += 1 self.channels[channel.id] = ChannelLogs(channel, self) + self.channels[channel.id].preload(channel) workers += [ Worker(self.channels[channel.id], channel, start_date, stop_date) ] diff --git a/src/scanners/scanner.py b/src/scanners/scanner.py index e5b41b4..4c35238 100644 --- a/src/scanners/scanner.py +++ b/src/scanners/scanner.py @@ -15,6 +15,7 @@ from utils import ( RELATIVE_REGEX, parse_time, command_cache, + FilterLevel, ) from logs import ( GuildLogs, @@ -27,7 +28,7 @@ from logs import ( class Scanner(ABC): - VALID_ARGS = ["me", "here", "fast", "fresh", "mobile", "mention"] + VALID_ARGS = ["me", "here", "fast", "fresh", "mobile", "mention", "nsfw", "nsfw:allow", "nsfw:only"] def __init__( self, @@ -139,6 +140,26 @@ class Scanner(ABC): self.mention_users = "mention" in args or "mobile" in args + # nsfw filter + if "nsfw" in args or "nsfw:allow" in args: + self.nsfw = FilterLevel.ALLOW + elif "nsfw:only" in args: + self.nsfw = FilterLevel.ONLY + else: + self.nsfw = FilterLevel.NONE + + # fix nsfw filter if channel specified + if not self.full and any(channel.nsfw for channel in self.channels): + self.nsfw = FilterLevel.ALLOW + elif all(channel.nsfw for channel in self.channels): + self.nsfw = FilterLevel.ONLY + + # filter nsfw channels + if self.nsfw == FilterLevel.NONE: + self.channels = list(filter(lambda channel:not channel.nsfw, self.channels)) + elif self.nsfw == FilterLevel.ONLY: + self.channels = list(filter(lambda channel:channel.nsfw, self.channels)) + if not await self.init(message, *args): return diff --git a/src/utils/utils.py b/src/utils/utils.py index 19e158a..bc79fe2 100644 --- a/src/utils/utils.py +++ b/src/utils/utils.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Callable, List, Dict, Union, Optional, Any import os import logging @@ -17,6 +18,7 @@ COMMON_HELP_ARGS = [ " - filter before ", "fast - only read cache", "fresh - does not read cache (long)", + "nsfw:allow/only - allow messages from nsfw channels", "mobile/mention - mentions users (fix @invalid-user bug)", ] @@ -49,6 +51,12 @@ def deltas(t0: datetime): return (datetime.now() - t0).total_seconds() +class FilterLevel(Enum): + NONE = 0 + ALLOW = 1 + ONLY = 2 + + # DISCORD API From d5a3667cfb6fa1662fc31a26e80e8fe93934805d Mon Sep 17 00:00:00 2001 From: Klemek Date: Tue, 18 May 2021 18:13:51 +0200 Subject: [PATCH 05/13] prepare history scanner for images --- src/scanners/first_scanner.py | 3 +++ src/scanners/history_scanner.py | 24 +++++++++++++++++------- src/scanners/last_scanner.py | 3 +++ src/scanners/random_scanner.py | 3 +++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/scanners/first_scanner.py b/src/scanners/first_scanner.py index 1048e2c..ab98add 100644 --- a/src/scanners/first_scanner.py +++ b/src/scanners/first_scanner.py @@ -14,5 +14,8 @@ class FirstScanner(HistoryScanner): def __init__(self): super().__init__(help=FirstScanner.help()) + def allow_message(self, *_) -> bool: + return True + def get_results(self, intro: str) -> List[str]: return self.history.to_string(type="first") diff --git a/src/scanners/history_scanner.py b/src/scanners/history_scanner.py index 5a3ae4c..7ddcbaf 100644 --- a/src/scanners/history_scanner.py +++ b/src/scanners/history_scanner.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import List +from typing import Callable, List import discord # Custom libs @@ -10,10 +10,10 @@ from logs import ChannelLogs, MessageLog class HistoryScanner(Scanner, ABC): - def __init__(self, *, help: str): + def __init__(self, *, valid_args: List[str] = [], help: str): super().__init__( has_digit_args=True, - valid_args=["all", "everyone"], + valid_args=["all", "everyone"] + valid_args, help=help, intro_context="", ) @@ -30,12 +30,17 @@ class HistoryScanner(Scanner, ABC): self.history, self.raw_members, all_messages=self.all_messages, + allow_message=self.allow_message, ) @abstractmethod def get_results(self, intro: str): pass + @abstractmethod + def allow_message(self, channel: ChannelLogs, message: MessageLog) -> bool: + pass + @staticmethod def analyse_message( channel: ChannelLogs, @@ -44,14 +49,19 @@ class HistoryScanner(Scanner, ABC): raw_members: List[int], *, all_messages: bool, + allow_message: Callable ) -> bool: impacted = False # If author is included in the selection (empty list is all) if ( - (not message.bot or all_messages) - and len(raw_members) == 0 - or message.author in raw_members - ) and (message.content or message.attachment): + ( + (not message.bot or all_messages) + and len(raw_members) == 0 + or message.author in raw_members + ) + and (message.content or message.attachment) + and allow_message(channel, message) + ): impacted = True history.messages += [message] return impacted diff --git a/src/scanners/last_scanner.py b/src/scanners/last_scanner.py index 3d8cbf0..eb69fb4 100644 --- a/src/scanners/last_scanner.py +++ b/src/scanners/last_scanner.py @@ -14,5 +14,8 @@ class LastScanner(HistoryScanner): def __init__(self): super().__init__(help=LastScanner.help()) + def allow_message(self, *_) -> bool: + return True + def get_results(self, intro: str) -> List[str]: return self.history.to_string(type="last") diff --git a/src/scanners/random_scanner.py b/src/scanners/random_scanner.py index f4fb7a9..990ebd1 100644 --- a/src/scanners/random_scanner.py +++ b/src/scanners/random_scanner.py @@ -14,5 +14,8 @@ class RandomScanner(HistoryScanner): def __init__(self): super().__init__(help=RandomScanner.help()) + def allow_message(self, *_) -> bool: + return True + def get_results(self, intro: str) -> List[str]: return self.history.to_string(type="random") From c101002b6c372739493c796fff32853c23a17bab Mon Sep 17 00:00:00 2001 From: Klemek Date: Wed, 19 May 2021 11:43:32 +0200 Subject: [PATCH 06/13] backticks in %find can use regexes --- src/scanners/find_scanner.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/scanners/find_scanner.py b/src/scanners/find_scanner.py index b1dfd4a..8abf0e0 100644 --- a/src/scanners/find_scanner.py +++ b/src/scanners/find_scanner.py @@ -1,6 +1,7 @@ -from typing import Dict, List +from typing import Dict, List, Optional, Tuple from collections import defaultdict import discord +import re # Custom libs @@ -21,7 +22,7 @@ class FindScanner(Scanner): def help() -> str: return generate_help( "find", - "Find specific words or phrases (you can use quotes to add spaces in queries)", + "Find specific words or phrases (you can use quotes to add spaces in queries, backticks define regexes)", args=[ "top - rank users for these queries", "all/everyone - include bots", @@ -43,17 +44,18 @@ class FindScanner(Scanner): self.top = "top" in args or len(self.other_args) == 1 if len(self.other_args) == 0: await message.channel.send( - "You need to add a query to find (you can use quotes to add spaces in queries)", + "You need to add a query to find (you can use quotes to add spaces in queries, backticks define regexes)", reference=message, ) return False + self.queries = [(query, query.strip("`") if re.match(r"^`.*`$", query) else None) for query in self.other_args] return True def compute_message(self, channel: ChannelLogs, message: MessageLog): return FindScanner.analyse_message( message, self.matches, - self.other_args, + self.queries, self.raw_members, all_messages=self.all_messages, top=self.top, @@ -77,7 +79,9 @@ class FindScanner(Scanner): res += [ self.matches[match].to_string( matches.index(match), - f'"{escape_text(match)}"', + f'"{escape_text(match)}"' + if len(match.strip("`")) == len(match) + else match, total_usage=self.msg_count, ranking=False, transform=lambda id: f" by {mention(id)}", @@ -97,7 +101,7 @@ class FindScanner(Scanner): def analyse_message( message: MessageLog, matches: Dict[str, Counter], - queries: List[str], + queries: List[Tuple[str, Optional[str]]], raw_members: List[int], *, all_messages: bool, @@ -113,10 +117,13 @@ class FindScanner(Scanner): impacted = True content = message.content.lower() for query in queries: - count = content.count(query.lower()) + if query[1] is not None: + count = len(re.findall(query[1], message.content)) + else: + count = content.count(query[0].lower()) if top: if count > 0: matches[message.author].update_use(count, message.created_at) else: - matches[query].update_use(count, message.created_at, message.author) + matches[query[0]].update_use(count, message.created_at, message.author) return impacted From 1a17e232ed9110ba9c846ca6ad7533fc8834a68b Mon Sep 17 00:00:00 2001 From: Klemek Date: Wed, 19 May 2021 11:59:19 +0200 Subject: [PATCH 07/13] allow queries in %first/%history/%last --- src/scanners/first_scanner.py | 4 ++-- src/scanners/history_scanner.py | 24 ++++++++++++++++++++++-- src/scanners/last_scanner.py | 4 ++-- src/scanners/random_scanner.py | 4 ++-- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/scanners/first_scanner.py b/src/scanners/first_scanner.py index ab98add..0a2bb63 100644 --- a/src/scanners/first_scanner.py +++ b/src/scanners/first_scanner.py @@ -9,10 +9,10 @@ from utils import generate_help class FirstScanner(HistoryScanner): @staticmethod def help() -> str: - return generate_help("first", "Read first message") + return generate_help("first", "Read first message (add text to filter like %find)") def __init__(self): - super().__init__(help=FirstScanner.help()) + super().__init__(help=FirstScanner.help(), allow_queries=True) def allow_message(self, *_) -> bool: return True diff --git a/src/scanners/history_scanner.py b/src/scanners/history_scanner.py index 7ddcbaf..1848dd5 100644 --- a/src/scanners/history_scanner.py +++ b/src/scanners/history_scanner.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from typing import Callable, List import discord +import re # Custom libs @@ -10,17 +11,23 @@ from logs import ChannelLogs, MessageLog class HistoryScanner(Scanner, ABC): - def __init__(self, *, valid_args: List[str] = [], help: str): + def __init__(self, *, help: str, valid_args: List[str] = [], allow_queries: bool = False): super().__init__( has_digit_args=True, valid_args=["all", "everyone"] + valid_args, help=help, intro_context="", + all_args=allow_queries, ) + self.allow_queries = allow_queries async def init(self, message: discord.Message, *args: str) -> bool: self.history = History() self.all_messages = "all" in args or "everyone" in args + if self.allow_queries: + self.queries = [(query.lower(), query.strip("`") if re.match(r"^`.*`$", query) else None) for query in self.other_args] + else: + self.queries = [] return True def compute_message(self, channel: ChannelLogs, message: MessageLog): @@ -30,7 +37,7 @@ class HistoryScanner(Scanner, ABC): self.history, self.raw_members, all_messages=self.all_messages, - allow_message=self.allow_message, + allow_message=lambda *args: self.allow_message(*args) and self.allow_message_query(*args), ) @abstractmethod @@ -41,6 +48,19 @@ class HistoryScanner(Scanner, ABC): def allow_message(self, channel: ChannelLogs, message: MessageLog) -> bool: pass + def allow_message_query(self, channel: ChannelLogs, message: MessageLog) -> bool: + if not self.allow_queries or len(self.queries) == 0: + return True + else: + content = message.content.lower() + for query in self.queries: + if query[1] is not None: + if not re.match(query[1], message.content): + return False + elif not query[0] in content: + return False + return True + @staticmethod def analyse_message( channel: ChannelLogs, diff --git a/src/scanners/last_scanner.py b/src/scanners/last_scanner.py index eb69fb4..792c2ff 100644 --- a/src/scanners/last_scanner.py +++ b/src/scanners/last_scanner.py @@ -9,10 +9,10 @@ from utils import generate_help class LastScanner(HistoryScanner): @staticmethod def help() -> str: - return generate_help("last", "Read last message") + return generate_help("last", "Read last message (add text to filter like %find)") def __init__(self): - super().__init__(help=LastScanner.help()) + super().__init__(help=LastScanner.help(), allow_queries=True) def allow_message(self, *_) -> bool: return True diff --git a/src/scanners/random_scanner.py b/src/scanners/random_scanner.py index 990ebd1..c42d249 100644 --- a/src/scanners/random_scanner.py +++ b/src/scanners/random_scanner.py @@ -9,10 +9,10 @@ from utils import generate_help class RandomScanner(HistoryScanner): @staticmethod def help() -> str: - return generate_help("rand", "Read a random message") + return generate_help("rand", "Read a random message (add text to filter like %find)") def __init__(self): - super().__init__(help=RandomScanner.help()) + super().__init__(help=RandomScanner.help(), allow_queries=True) def allow_message(self, *_) -> bool: return True From 13447ff869451617af386963e4a4c4ca1882c014 Mon Sep 17 00:00:00 2001 From: Klemek Date: Wed, 19 May 2021 13:29:37 +0200 Subject: [PATCH 08/13] fix channel preload --- src/logs/guild_logs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/logs/guild_logs.py b/src/logs/guild_logs.py index 8e84b23..8b2a4fc 100644 --- a/src/logs/guild_logs.py +++ b/src/logs/guild_logs.py @@ -215,6 +215,8 @@ class GuildLogs: ] ) total_chan = len(target_channels) + for channel in target_channels: + self.channels[channel.id].preload(channel) else: if not self.locked and not self.lock(): return ALREADY_RUNNING, 0 From 516eb75b5c5643ca8ebdeccfc05f7f2dbc006076 Mon Sep 17 00:00:00 2001 From: Klemek Date: Wed, 19 May 2021 13:31:07 +0200 Subject: [PATCH 09/13] %first/%rand/%last image --- src/data_types/history.py | 49 ++++++++++++++++++++++++++++++++- src/logs/message_log.py | 11 ++++++-- src/scanners/first_scanner.py | 18 +++++++----- src/scanners/history_scanner.py | 44 ++++++++++++----------------- src/scanners/last_scanner.py | 18 +++++++----- src/scanners/random_scanner.py | 18 +++++++----- src/scanners/scanner.py | 23 ++++++++++++---- src/utils/utils.py | 3 ++ 8 files changed, 129 insertions(+), 55 deletions(-) diff --git a/src/data_types/history.py b/src/data_types/history.py index 27dff33..c2a7229 100644 --- a/src/data_types/history.py +++ b/src/data_types/history.py @@ -3,13 +3,60 @@ import random # Custom libs -from utils import mention, from_now, str_datetime, message_link +from utils import mention, from_now, str_datetime, message_link, SPLIT_TOKEN +MAX_RANDOM_TRIES = 10 class History: def __init__(self): self.messages = [] + async def to_string_image(self, *, type: str) -> List[str]: + if len(self.messages) == 0: + return ["There was no messages matching your filters"] + message = None + intro = None + real_message = None + if type == "first": + self.messages.sort(key=lambda m: m.created_at) + index = 0 + while real_message is None and index < len(self.messages): + message = self.messages[index] + real_message = await message.fetch() + index += 1 + intro = f"First image out of {len(self.messages):,}" + elif type == "last": + self.messages.sort(key=lambda m: m.created_at, reverse=True) + index = 0 + while real_message is None and index < len(self.messages): + message = self.messages[index] + real_message = await message.fetch() + index += 1 + intro = f"Last image out of {len(self.messages):,}" + elif type == "random": + intro = f"Random image out of {len(self.messages):,}" + tries = 0 + while real_message is None and tries < MAX_RANDOM_TRIES: + message = random.choice(self.messages) + real_message = await message.fetch() + tries += 1 + + if real_message is None: + return ["There was no messages matching your filters"] + image = "" + if len(real_message.attachments) > 0: + image = real_message.attachments[0].url + elif len(real_message.embeds) > 0: + image = real_message.embeds[0].url + + return [ + intro, + f"{str_datetime(message.created_at)} ({from_now(message.created_at)}) {mention(message.author)} sent:", + f"<{message_link(message)}>", + SPLIT_TOKEN, + image, + ] + def to_string(self, *, type: str) -> List[str]: if len(self.messages) == 0: return ["There was no messages matching your filters"] diff --git a/src/logs/message_log.py b/src/logs/message_log.py index 67e9205..7ef51e6 100644 --- a/src/logs/message_log.py +++ b/src/logs/message_log.py @@ -1,11 +1,11 @@ -from typing import Union, Any +from typing import Optional, Union, Any import discord from datetime import datetime from utils import is_extension, serialize IMAGE_FORMAT = [".gif", ".gifv", ".png", ".jpg", ".jpeg", ".bmp"] -EMBED_IMAGES = ["image", "gifv", "gif"] +EMBED_IMAGES = ["image", "gifv"] class MessageLog: @@ -76,6 +76,13 @@ class MessageLog: self.reactions[str(reaction.emoji)] = [] async for user in reaction.users(): self.reactions[str(reaction.emoji)] += [user.id] + + async def fetch(self) -> Optional[discord.Message]: + try: + return await self.channel.channel.fetch_message(self.id) + except (discord.NotFound, discord.Forbidden, discord.HTTPException): + return None + def dict(self) -> dict: return serialize( diff --git a/src/scanners/first_scanner.py b/src/scanners/first_scanner.py index 0a2bb63..a75f23c 100644 --- a/src/scanners/first_scanner.py +++ b/src/scanners/first_scanner.py @@ -9,13 +9,17 @@ from utils import generate_help class FirstScanner(HistoryScanner): @staticmethod def help() -> str: - return generate_help("first", "Read first message (add text to filter like %find)") + return generate_help( + "first", + "Read first message (add text to filter like %find)", + args=["image - pull an image instead of a message"], + ) def __init__(self): - super().__init__(help=FirstScanner.help(), allow_queries=True) + super().__init__(help=FirstScanner.help()) - def allow_message(self, *_) -> bool: - return True - - def get_results(self, intro: str) -> List[str]: - return self.history.to_string(type="first") + async def get_results(self, intro: str) -> List[str]: + if self.images_only: + return await self.history.to_string_image(type="first") + else: + return self.history.to_string(type="first") diff --git a/src/scanners/history_scanner.py b/src/scanners/history_scanner.py index 1848dd5..6493137 100644 --- a/src/scanners/history_scanner.py +++ b/src/scanners/history_scanner.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Callable, List +from typing import List, Tuple, Optional import discord import re @@ -11,20 +11,20 @@ from logs import ChannelLogs, MessageLog class HistoryScanner(Scanner, ABC): - def __init__(self, *, help: str, valid_args: List[str] = [], allow_queries: bool = False): + def __init__(self, *, help: str): super().__init__( has_digit_args=True, - valid_args=["all", "everyone"] + valid_args, + valid_args=["all", "everyone"], help=help, intro_context="", - all_args=allow_queries, + all_args=True, ) - self.allow_queries = allow_queries async def init(self, message: discord.Message, *args: str) -> bool: self.history = History() self.all_messages = "all" in args or "everyone" in args - if self.allow_queries: + self.images_only = "image" in args + if not self.images_only: self.queries = [(query.lower(), query.strip("`") if re.match(r"^`.*`$", query) else None) for query in self.other_args] else: self.queries = [] @@ -37,30 +37,14 @@ class HistoryScanner(Scanner, ABC): self.history, self.raw_members, all_messages=self.all_messages, - allow_message=lambda *args: self.allow_message(*args) and self.allow_message_query(*args), + queries=self.queries, + images_only=self.images_only, ) @abstractmethod def get_results(self, intro: str): pass - @abstractmethod - def allow_message(self, channel: ChannelLogs, message: MessageLog) -> bool: - pass - - def allow_message_query(self, channel: ChannelLogs, message: MessageLog) -> bool: - if not self.allow_queries or len(self.queries) == 0: - return True - else: - content = message.content.lower() - for query in self.queries: - if query[1] is not None: - if not re.match(query[1], message.content): - return False - elif not query[0] in content: - return False - return True - @staticmethod def analyse_message( channel: ChannelLogs, @@ -69,7 +53,8 @@ class HistoryScanner(Scanner, ABC): raw_members: List[int], *, all_messages: bool, - allow_message: Callable + queries: List[Tuple[str, Optional[str]]], + images_only: bool, ) -> bool: impacted = False # If author is included in the selection (empty list is all) @@ -80,8 +65,15 @@ class HistoryScanner(Scanner, ABC): or message.author in raw_members ) and (message.content or message.attachment) - and allow_message(channel, message) + and (not images_only or message.image) ): + content = message.content.lower() + for query in queries: + if query[1] is not None: + if not re.match(query[1], message.content): + return False + elif not query[0] in content: + return False impacted = True history.messages += [message] return impacted diff --git a/src/scanners/last_scanner.py b/src/scanners/last_scanner.py index 792c2ff..718af8b 100644 --- a/src/scanners/last_scanner.py +++ b/src/scanners/last_scanner.py @@ -9,13 +9,17 @@ from utils import generate_help class LastScanner(HistoryScanner): @staticmethod def help() -> str: - return generate_help("last", "Read last message (add text to filter like %find)") + return generate_help( + "last", + "Read last message (add text to filter like %find)", + args=["image - pull an image instead of a message"], + ) def __init__(self): - super().__init__(help=LastScanner.help(), allow_queries=True) + super().__init__(help=LastScanner.help()) - def allow_message(self, *_) -> bool: - return True - - def get_results(self, intro: str) -> List[str]: - return self.history.to_string(type="last") + async def get_results(self, intro: str) -> List[str]: + if self.images_only: + return await self.history.to_string_image(type="last") + else: + return self.history.to_string(type="last") diff --git a/src/scanners/random_scanner.py b/src/scanners/random_scanner.py index c42d249..d761efe 100644 --- a/src/scanners/random_scanner.py +++ b/src/scanners/random_scanner.py @@ -9,13 +9,17 @@ from utils import generate_help class RandomScanner(HistoryScanner): @staticmethod def help() -> str: - return generate_help("rand", "Read a random message (add text to filter like %find)") + return generate_help( + "rand", + "Read a random message (add text to filter like %find)", + args=["image - pull an image instead of a message"], + ) def __init__(self): - super().__init__(help=RandomScanner.help(), allow_queries=True) + super().__init__(help=RandomScanner.help()) - def allow_message(self, *_) -> bool: - return True - - def get_results(self, intro: str) -> List[str]: - return self.history.to_string(type="random") + async def get_results(self, intro: str) -> List[str]: + if self.images_only: + return await self.history.to_string_image(type="random") + else: + return self.history.to_string(type="random") diff --git a/src/scanners/scanner.py b/src/scanners/scanner.py index 4c35238..5adc7b8 100644 --- a/src/scanners/scanner.py +++ b/src/scanners/scanner.py @@ -4,6 +4,7 @@ from datetime import datetime import logging import re import discord +import inspect from utils import ( @@ -16,6 +17,7 @@ from utils import ( parse_time, command_cache, FilterLevel, + SPLIT_TOKEN, ) from logs import ( GuildLogs, @@ -241,8 +243,7 @@ class Scanner(ABC): await progress.edit(content="```Computing results...```") # Display results t0 = datetime.now() - results = self.get_results( - get_intro( + intro = get_intro( self.intro_context, self.full, self.channels, @@ -252,7 +253,10 @@ class Scanner(ABC): self.start_date, self.stop_date, ) - ) + if inspect.iscoroutinefunction(self.get_results): + results = await self.get_results(intro) + else: + results = self.get_results(intro) logging.info( f"scan {guild.id} > results in {delta(t0):,}ms" ) @@ -265,7 +269,7 @@ class Scanner(ABC): ) for r in results: if r: - if len(response + "\n" + r) > 2000: + if isinstance(r, int) and r == SPLIT_TOKEN: await message.channel.send( response, reference=message if first else None, @@ -273,7 +277,16 @@ class Scanner(ABC): ) first = False response = "" - response += "\n" + r + elif isinstance(r, str): + if len(response + "\n" + r) > 2000: + await message.channel.send( + response, + reference=message if first else None, + allowed_mentions=allowed_mentions, + ) + first = False + response = "" + response += "\n" + r if len(response) > 0: await message.channel.send( response, diff --git a/src/utils/utils.py b/src/utils/utils.py index bc79fe2..74c0f0c 100644 --- a/src/utils/utils.py +++ b/src/utils/utils.py @@ -57,6 +57,9 @@ class FilterLevel(Enum): ONLY = 2 +SPLIT_TOKEN = 1152317803 + + # DISCORD API From da5e3fdb35ef00f9fe59931bab3af13b2eb748df Mon Sep 17 00:00:00 2001 From: Klemek Date: Wed, 19 May 2021 13:33:15 +0200 Subject: [PATCH 10/13] blacked --- src/data_types/history.py | 3 ++- src/logs/channel_logs.py | 4 +--- src/logs/message_log.py | 3 +-- src/scanners/find_scanner.py | 9 +++++-- src/scanners/history_scanner.py | 8 ++++++- src/scanners/scanner.py | 42 ++++++++++++++++++++++----------- src/utils/utils.py | 1 + 7 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/data_types/history.py b/src/data_types/history.py index c2a7229..5354bde 100644 --- a/src/data_types/history.py +++ b/src/data_types/history.py @@ -7,6 +7,7 @@ from utils import mention, from_now, str_datetime, message_link, SPLIT_TOKEN MAX_RANDOM_TRIES = 10 + class History: def __init__(self): self.messages = [] @@ -40,7 +41,7 @@ class History: message = random.choice(self.messages) real_message = await message.fetch() tries += 1 - + if real_message is None: return ["There was no messages matching your filters"] image = "" diff --git a/src/logs/channel_logs.py b/src/logs/channel_logs.py index 65d45c2..07bb14e 100644 --- a/src/logs/channel_logs.py +++ b/src/logs/channel_logs.py @@ -131,8 +131,6 @@ class ChannelLogs: yield len(self.messages), True def dict(self) -> dict: - channel = serialize( - self, not_serialized=["channel", "guild", "start_date"] - ) + channel = serialize(self, not_serialized=["channel", "guild", "start_date"]) channel["messages"] = [message.dict() for message in self.messages] return channel diff --git a/src/logs/message_log.py b/src/logs/message_log.py index 7ef51e6..bd69b4b 100644 --- a/src/logs/message_log.py +++ b/src/logs/message_log.py @@ -76,14 +76,13 @@ class MessageLog: self.reactions[str(reaction.emoji)] = [] async for user in reaction.users(): self.reactions[str(reaction.emoji)] += [user.id] - + async def fetch(self) -> Optional[discord.Message]: try: return await self.channel.channel.fetch_message(self.id) except (discord.NotFound, discord.Forbidden, discord.HTTPException): return None - def dict(self) -> dict: return serialize( self, not_serialized=["channel"], dates=["created_at", "edited_at"] diff --git a/src/scanners/find_scanner.py b/src/scanners/find_scanner.py index 8abf0e0..54ece5c 100644 --- a/src/scanners/find_scanner.py +++ b/src/scanners/find_scanner.py @@ -48,7 +48,10 @@ class FindScanner(Scanner): reference=message, ) return False - self.queries = [(query, query.strip("`") if re.match(r"^`.*`$", query) else None) for query in self.other_args] + self.queries = [ + (query, query.strip("`") if re.match(r"^`.*`$", query) else None) + for query in self.other_args + ] return True def compute_message(self, channel: ChannelLogs, message: MessageLog): @@ -125,5 +128,7 @@ class FindScanner(Scanner): if count > 0: matches[message.author].update_use(count, message.created_at) else: - matches[query[0]].update_use(count, message.created_at, message.author) + matches[query[0]].update_use( + count, message.created_at, message.author + ) return impacted diff --git a/src/scanners/history_scanner.py b/src/scanners/history_scanner.py index 6493137..52f2507 100644 --- a/src/scanners/history_scanner.py +++ b/src/scanners/history_scanner.py @@ -25,7 +25,13 @@ class HistoryScanner(Scanner, ABC): self.all_messages = "all" in args or "everyone" in args self.images_only = "image" in args if not self.images_only: - self.queries = [(query.lower(), query.strip("`") if re.match(r"^`.*`$", query) else None) for query in self.other_args] + self.queries = [ + ( + query.lower(), + query.strip("`") if re.match(r"^`.*`$", query) else None, + ) + for query in self.other_args + ] else: self.queries = [] return True diff --git a/src/scanners/scanner.py b/src/scanners/scanner.py index 5adc7b8..dbfb831 100644 --- a/src/scanners/scanner.py +++ b/src/scanners/scanner.py @@ -30,7 +30,17 @@ from logs import ( class Scanner(ABC): - VALID_ARGS = ["me", "here", "fast", "fresh", "mobile", "mention", "nsfw", "nsfw:allow", "nsfw:only"] + VALID_ARGS = [ + "me", + "here", + "fast", + "fresh", + "mobile", + "mention", + "nsfw", + "nsfw:allow", + "nsfw:only", + ] def __init__( self, @@ -149,18 +159,22 @@ class Scanner(ABC): self.nsfw = FilterLevel.ONLY else: self.nsfw = FilterLevel.NONE - + # fix nsfw filter if channel specified if not self.full and any(channel.nsfw for channel in self.channels): self.nsfw = FilterLevel.ALLOW elif all(channel.nsfw for channel in self.channels): self.nsfw = FilterLevel.ONLY - + # filter nsfw channels if self.nsfw == FilterLevel.NONE: - self.channels = list(filter(lambda channel:not channel.nsfw, self.channels)) + self.channels = list( + filter(lambda channel: not channel.nsfw, self.channels) + ) elif self.nsfw == FilterLevel.ONLY: - self.channels = list(filter(lambda channel:channel.nsfw, self.channels)) + self.channels = list( + filter(lambda channel: channel.nsfw, self.channels) + ) if not await self.init(message, *args): return @@ -244,15 +258,15 @@ class Scanner(ABC): # Display results t0 = datetime.now() intro = get_intro( - self.intro_context, - self.full, - self.channels, - self.members, - self.msg_count, - self.chan_count, - self.start_date, - self.stop_date, - ) + self.intro_context, + self.full, + self.channels, + self.members, + self.msg_count, + self.chan_count, + self.start_date, + self.stop_date, + ) if inspect.iscoroutinefunction(self.get_results): results = await self.get_results(intro) else: diff --git a/src/utils/utils.py b/src/utils/utils.py index 74c0f0c..2fd7413 100644 --- a/src/utils/utils.py +++ b/src/utils/utils.py @@ -99,6 +99,7 @@ class FakeMessage: def __init__(self, id: int): self.id = id + # FILE From a8b1ede9627736058ff3b7876d92ce447a0f86fd Mon Sep 17 00:00:00 2001 From: Klemek Date: Wed, 19 May 2021 15:11:29 +0200 Subject: [PATCH 11/13] spoiler filtering --- src/data_types/history.py | 26 +++++++++++++++++++++++--- src/scanners/first_scanner.py | 9 +++++++-- src/scanners/history_scanner.py | 9 ++++++++- src/scanners/last_scanner.py | 7 +++++-- src/scanners/random_scanner.py | 9 +++++++-- src/utils/utils.py | 19 +++++++++++++++++++ 6 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/data_types/history.py b/src/data_types/history.py index 5354bde..8a23925 100644 --- a/src/data_types/history.py +++ b/src/data_types/history.py @@ -3,16 +3,24 @@ import random # Custom libs -from utils import mention, from_now, str_datetime, message_link, SPLIT_TOKEN +from utils import ( + mention, + from_now, + str_datetime, + message_link, + SPLIT_TOKEN, + FilterLevel, + should_allow_spoiler, +) -MAX_RANDOM_TRIES = 10 +MAX_RANDOM_TRIES = 100 class History: def __init__(self): self.messages = [] - async def to_string_image(self, *, type: str) -> List[str]: + async def to_string_image(self, *, type: str, spoiler: FilterLevel) -> List[str]: if len(self.messages) == 0: return ["There was no messages matching your filters"] message = None @@ -24,6 +32,10 @@ class History: while real_message is None and index < len(self.messages): message = self.messages[index] real_message = await message.fetch() + if real_message is not None and not should_allow_spoiler( + real_message, spoiler + ): + real_message = None index += 1 intro = f"First image out of {len(self.messages):,}" elif type == "last": @@ -32,6 +44,10 @@ class History: while real_message is None and index < len(self.messages): message = self.messages[index] real_message = await message.fetch() + if real_message is not None and not should_allow_spoiler( + real_message, spoiler + ): + real_message = None index += 1 intro = f"Last image out of {len(self.messages):,}" elif type == "random": @@ -40,6 +56,10 @@ class History: while real_message is None and tries < MAX_RANDOM_TRIES: message = random.choice(self.messages) real_message = await message.fetch() + if real_message is not None and not should_allow_spoiler( + real_message, spoiler + ): + real_message = None tries += 1 if real_message is None: diff --git a/src/scanners/first_scanner.py b/src/scanners/first_scanner.py index a75f23c..ba7622b 100644 --- a/src/scanners/first_scanner.py +++ b/src/scanners/first_scanner.py @@ -12,7 +12,10 @@ class FirstScanner(HistoryScanner): return generate_help( "first", "Read first message (add text to filter like %find)", - args=["image - pull an image instead of a message"], + args=[ + "image - pull an image instead of a message", + "spoiler:allow/only - allow spoiler images", + ], ) def __init__(self): @@ -20,6 +23,8 @@ class FirstScanner(HistoryScanner): async def get_results(self, intro: str) -> List[str]: if self.images_only: - return await self.history.to_string_image(type="first") + return await self.history.to_string_image( + type="first", spoiler=self.spoiler + ) else: return self.history.to_string(type="first") diff --git a/src/scanners/history_scanner.py b/src/scanners/history_scanner.py index 52f2507..aecf5a5 100644 --- a/src/scanners/history_scanner.py +++ b/src/scanners/history_scanner.py @@ -8,13 +8,14 @@ import re from .scanner import Scanner from data_types import History from logs import ChannelLogs, MessageLog +from utils import FilterLevel class HistoryScanner(Scanner, ABC): def __init__(self, *, help: str): super().__init__( has_digit_args=True, - valid_args=["all", "everyone"], + valid_args=["all", "everyone", "spoiler", "spoiler:allow", "spoiler:only"], help=help, intro_context="", all_args=True, @@ -24,6 +25,12 @@ class HistoryScanner(Scanner, ABC): self.history = History() self.all_messages = "all" in args or "everyone" in args self.images_only = "image" in args + if "spoiler" in args or "spoiler:allow" in args: + self.spoiler = FilterLevel.ALLOW + elif "spoiler:only" in args: + self.spoiler = FilterLevel.ONLY + else: + self.spoiler = FilterLevel.NONE if not self.images_only: self.queries = [ ( diff --git a/src/scanners/last_scanner.py b/src/scanners/last_scanner.py index 718af8b..055217e 100644 --- a/src/scanners/last_scanner.py +++ b/src/scanners/last_scanner.py @@ -12,7 +12,10 @@ class LastScanner(HistoryScanner): return generate_help( "last", "Read last message (add text to filter like %find)", - args=["image - pull an image instead of a message"], + args=[ + "image - pull an image instead of a message", + "spoiler:allow/only - allow spoiler images", + ], ) def __init__(self): @@ -20,6 +23,6 @@ class LastScanner(HistoryScanner): async def get_results(self, intro: str) -> List[str]: if self.images_only: - return await self.history.to_string_image(type="last") + return await self.history.to_string_image(type="last", spoiler=self.spoiler) else: return self.history.to_string(type="last") diff --git a/src/scanners/random_scanner.py b/src/scanners/random_scanner.py index d761efe..42d4488 100644 --- a/src/scanners/random_scanner.py +++ b/src/scanners/random_scanner.py @@ -12,7 +12,10 @@ class RandomScanner(HistoryScanner): return generate_help( "rand", "Read a random message (add text to filter like %find)", - args=["image - pull an image instead of a message"], + args=[ + "image - pull an image instead of a message", + "spoiler:allow/only - allow spoiler images", + ], ) def __init__(self): @@ -20,6 +23,8 @@ class RandomScanner(HistoryScanner): async def get_results(self, intro: str) -> List[str]: if self.images_only: - return await self.history.to_string_image(type="random") + return await self.history.to_string_image( + type="random", spoiler=self.spoiler + ) else: return self.history.to_string(type="random") diff --git a/src/utils/utils.py b/src/utils/utils.py index 2fd7413..a2e7c40 100644 --- a/src/utils/utils.py +++ b/src/utils/utils.py @@ -100,6 +100,25 @@ class FakeMessage: self.id = id +def is_image_spoiler(message: discord.Message) -> bool: + if len(message.attachments) > 0: + return message.attachments[0].is_spoiler() + elif len(message.embeds) > 0: + return re.match(r"||[^|]*http[^|]||", message.content.lower()) is not None + else: + return False + + +def should_allow_spoiler(message: discord.Message, spoiler: FilterLevel) -> bool: + is_spoiler = is_image_spoiler(message) + return ( + not is_spoiler + and spoiler <= FilterLevel.ALLOW + or is_spoiler + and spoiler >= FilterLevel.ALLOW + ) + + # FILE From a6f99256efe76451ad2c899fce65e85ce6e1586b Mon Sep 17 00:00:00 2001 From: Klemek Date: Wed, 19 May 2021 15:14:07 +0200 Subject: [PATCH 12/13] updated README --- README.md | 6 ++++++ src/main.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 32b94a1..4624eb3 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,12 @@ python3 src/main.py ## Changelog +* **v1.15** + * `nsfw:allow/only` filter nsfw channels + * `%find` can use regexes + * `%first`, `%rand` and `%last` can be filter with specific keywords + * `%first`, `%rand` and `%last` can pull images + * bug fix * **v1.14** * `mobile/mention` arg to fix mobile bug * `%repeat`, `%mobile` to repeat commands diff --git a/src/main.py b/src/main.py index d8f5fb4..3fc6ce9 100644 --- a/src/main.py +++ b/src/main.py @@ -18,7 +18,7 @@ emojis.load_emojis() bot = Bot( "Discord Analyst", - "1.14", + "1.15", alias="%", ) From 2d32dc37bf217d8fa4c8e88f359d942773d22bb3 Mon Sep 17 00:00:00 2001 From: Klemek Date: Wed, 19 May 2021 15:16:43 +0200 Subject: [PATCH 13/13] updated README --- README.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4624eb3..d9b73ec 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,24 @@ * %freq - frequency analysis * %compo - composition analysis * %pres - presence analysis -* %first - read first message -* %rand - read a random message -* %last - read last message -* %find - find specific words or phrases * %repeat - repeat last analysis (adding supplied arguments) * %mobile - fix @invalid-user for last command but mentions users * %gdpr - displays GDPR information +* %find - find specific words or phrases (you can use quotes to add spaces in queries, backticks define regexes) + * arguments: + * top - rank users for these queries +* %first - read first message (add text to filter like %find) + * arguments: + * image - pull an image instead of a message + * spoiler:allow/only - allow spoiler images +* %rand - read a random message (add text to filter like %find) + * arguments: + * image - pull an image instead of a message + * spoiler:allow/only - allow spoiler images +* %last - read last message (add text to filter like %find) + * arguments: + * image - pull an image instead of a message + * spoiler:allow/only - allow spoiler images * %emojis - rank emojis by their usage * arguments: * - top emojis, default is 20 @@ -61,6 +72,7 @@ * all/everyone - include bots messages * fast: only read cache * fresh: does not read cache + * nsfw:allow/only - allow messages from nsfw channels * mobile/mention: mentions users (fix @invalid-user bug) (Sample dates: 2020 / 2021-11 / 2021-06-28 / 2020-06-28T23:00 / today / week / 8days / 1y)