diff --git a/README.md b/README.md index 4406d1b..a3d936b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +![Python Version >= 3.7](https://img.shields.io/badge/python-%3E=3.7%20-blue) [![Scc Count Badge](https://sloc.xyz/github/klemek/discord-analyst/?category=code)](https://github.com/boyter/scc/#badges-beta) [![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/Klemek/discord-analyst.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/Klemek/discord-analyst/context:python) [![Total alerts](https://img.shields.io/lgtm/alerts/g/Klemek/discord-analyst.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/Klemek/discord-analyst/alerts/) @@ -17,6 +18,9 @@ * %freq - frequency analysis * %compo - composition analysis * %pres - presence analysis +* %first - read first message +* %rand - read a random message +* %last - read last message * %emojis - rank emotes by their usage * arguments: * - top emojis, default is 20 @@ -96,6 +100,9 @@ python3 src/main.py ## Changelog +* **v1.11** + * more scans `%first`, `%rand`, `%last` + * streak computing in `%pres` * **v1.10** * multithreading for queries * bug fix diff --git a/src/data_types/__init__.py b/src/data_types/__init__.py index 38daabd..e4be59f 100644 --- a/src/data_types/__init__.py +++ b/src/data_types/__init__.py @@ -3,3 +3,4 @@ from .frequency import Frequency from .composition import Composition from .presence import Presence from .counter import Counter +from .history import History diff --git a/src/data_types/counter.py b/src/data_types/counter.py index 8511074..c59ad43 100644 --- a/src/data_types/counter.py +++ b/src/data_types/counter.py @@ -49,7 +49,7 @@ class Counter: else: output += f"**#{i + 1}**" sum = val_sum(self.usages) - output += f" {name} - {plural(sum, counted)} ({percent(sum/total_usage)}) (last {from_now(self.last_used)})" + output += f" {name} - {plural(sum, counted)} ({percent(sum/total_usage)}, last {from_now(self.last_used)})" top_item = top_key(self.usages) if top_item != 0 and transform is not None: if self.usages[top_item] == sum: diff --git a/src/data_types/frequency.py b/src/data_types/frequency.py index 4eb622d..14cf5dd 100644 --- a/src/data_types/frequency.py +++ b/src/data_types/frequency.py @@ -2,7 +2,16 @@ from typing import List from datetime import timedelta import calendar -from utils import str_date, str_datetime, from_now, plural, percent, precise, top_key +from utils import ( + str_date, + str_datetime, + from_now, + plural, + percent, + precise, + top_key, + mention, +) class Frequency: @@ -16,8 +25,19 @@ class Frequency: self.busiest_day_count = 0 self.busiest_hour = None self.busiest_hour_count = 0 + self.streaks = [] + self.last_author = None + self.last_streak_start = None + self.last_streak_author = None + self.longest_streak = None + self.longest_streak_start = None + self.longest_streak_author = None - def to_string(self) -> List[str]: + def to_string( + self, + *, + member_specific: bool, + ) -> List[str]: delta = self.dates[-1] - self.dates[0] total_msg = len(self.dates) busiest_weekday = top_key(self.week) @@ -31,14 +51,24 @@ class Frequency: n_hours = delta.days if self.dates[0].hour <= busiest_hour and self.dates[-1].hour >= busiest_hour: n_hours += 1 - return [ + ret = [ f"- **earliest message**: {str_datetime(self.dates[0])} ({from_now(self.dates[0])})", f"- **latest message**: {str_datetime(self.dates[-1])} ({from_now(self.dates[-1])})", f"- **messages/day**: {precise(total_msg/delta.days, precision=3)}", f"- **busiest day of week**: {calendar.day_name[busiest_weekday]} (~{precise(self.week[busiest_weekday]/n_weekdays, precision=3)} msg, {percent(self.week[busiest_weekday]/total_msg)})", - f"- **busiest day ever**: {str_date(self.busiest_day)} ({from_now(self.busiest_day)}) ({self.busiest_day_count} msg)", + f"- **busiest day ever**: {str_date(self.busiest_day)} ({from_now(self.busiest_day)}, {self.busiest_day_count} msg)", f"- **messages/hour**: {precise(total_msg*3600/delta.total_seconds(), precision=3)}", f"- **busiest hour of day**: {busiest_hour:0>2}:00 (~{precise(self.day[busiest_hour]/n_hours, precision=3)} msg, {percent(self.day[busiest_hour]/total_msg)})", - f"- **busiest hour ever**: {str_datetime(self.busiest_hour)} ({from_now(self.busiest_hour)}) ({self.busiest_hour_count} msg)", + f"- **busiest hour ever**: {str_datetime(self.busiest_hour)} ({from_now(self.busiest_hour)}, {self.busiest_hour_count} msg)", f"- **longest break**: {plural(round(self.longest_break.total_seconds()/3600), 'hour')} ({plural(self.longest_break.days,'day')}) from {str_datetime(self.longest_break_start)} ({from_now(self.longest_break_start)})", + f"- **avg. streak**: {precise(sum(self.streaks)/len(self.streaks), precision=3)} msg", ] + if member_specific: + ret += [ + f"- **longest streak**: {self.longest_streak:,} msg from {str_datetime(self.longest_streak_start)} ({from_now(self.longest_streak_start)})" + ] + else: + ret += [ + f"- **longest streak**: {mention(self.longest_streak_author)} ({self.longest_streak:,} msg from {str_datetime(self.longest_streak_start)}, {from_now(self.longest_streak_start)})" + ] + return ret diff --git a/src/data_types/history.py b/src/data_types/history.py new file mode 100644 index 0000000..27dff33 --- /dev/null +++ b/src/data_types/history.py @@ -0,0 +1,39 @@ +from typing import List +import random + +# Custom libs + +from utils import mention, from_now, str_datetime, message_link + + +class History: + def __init__(self): + self.messages = [] + + def to_string(self, *, type: str) -> List[str]: + if len(self.messages) == 0: + return ["There was no messages matching your filters"] + message = None + intro = None + if type == "first": + self.messages.sort(key=lambda m: m.created_at) + message = self.messages[0] + intro = f"First message out of {len(self.messages):,}" + elif type == "last": + self.messages.sort(key=lambda m: m.created_at, reverse=True) + message = self.messages[0] + intro = f"Last message out of {len(self.messages):,}" + elif type == "random": + message = random.choice(self.messages) + intro = f"Random message out of {len(self.messages):,}" + + text = ["> " + line for line in message.content.splitlines()] + if message.attachment: + text += ["> " if message.image else "> "] + + return [ + intro, + f"{str_datetime(message.created_at)} ({from_now(message.created_at)}) {mention(message.author)} said:", + *text, + f"<{message_link(message)}>", + ] diff --git a/src/logs/channel_logs.py b/src/logs/channel_logs.py index 91ce4df..615fcd1 100644 --- a/src/logs/channel_logs.py +++ b/src/logs/channel_logs.py @@ -1,4 +1,4 @@ -from typing import Union, Tuple +from typing import Union, Tuple, Any import discord from . import MessageLog @@ -9,7 +9,8 @@ FORMAT = 3 class ChannelLogs: - def __init__(self, channel: Union[discord.TextChannel, dict]): + def __init__(self, channel: Union[discord.TextChannel, dict], guild: Any): + self.guild = guild if isinstance(channel, discord.TextChannel): self.id = channel.id self.name = channel.name @@ -27,7 +28,7 @@ class ChannelLogs: if channel["last_message_id"] is not None else None ) - self.messages = [MessageLog(message) for message in channel["messages"]] + self.messages = [MessageLog(message, self) for message in channel["messages"]] def is_format(self): return self.format == FORMAT @@ -44,7 +45,7 @@ class ChannelLogs: oldest_first=True, ): self.last_message_id = message.id - m = MessageLog(message) + m = MessageLog(message, self) await m.load(message) self.messages.insert(0, m) yield len(self.messages), False @@ -64,7 +65,7 @@ class ChannelLogs: ): done += 1 last_message_id = message.id - m = MessageLog(message) + m = MessageLog(message, self) await m.load(message) self.messages += [m] yield len(self.messages), False diff --git a/src/logs/guild_logs.py b/src/logs/guild_logs.py index ed96ad6..7f3cecb 100644 --- a/src/logs/guild_logs.py +++ b/src/logs/guild_logs.py @@ -49,6 +49,7 @@ class Worker: class GuildLogs: def __init__(self, guild: discord.Guild): + self.id = guild.id self.guild = guild self.log_file = os.path.join(LOG_DIR, f"{guild.id}.logz") self.channels = {} @@ -104,7 +105,9 @@ class GuildLogs: return CANCELLED, 0 await code_message(progress, "Reading saved history (4/4)...") t0 = datetime.now() - self.channels = {int(id): ChannelLogs(channels[id]) for id in channels} + self.channels = { + int(id): ChannelLogs(channels[id], self) for id in channels + } # remove invalid format self.channels = { id: self.channels[id] @@ -154,7 +157,7 @@ class GuildLogs: for channel in target_channels: if channel.id not in self.channels or fresh: loading_new += 1 - self.channels[channel.id] = ChannelLogs(channel) + self.channels[channel.id] = ChannelLogs(channel, self) workers += [Worker(self.channels[channel.id], channel)] warning_msg = "(this might take a while)" if len(target_channels) > 5 and loading_new > 5: diff --git a/src/logs/message_log.py b/src/logs/message_log.py index fe733b5..b77ea49 100644 --- a/src/logs/message_log.py +++ b/src/logs/message_log.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Union, Any import discord from datetime import datetime @@ -9,7 +9,8 @@ EMBED_IMAGES = ["image", "gifv"] class MessageLog: - def __init__(self, message: Union[discord.Message, dict]): + def __init__(self, message: Union[discord.Message, dict], channel: Any): + self.channel = channel if isinstance(message, discord.Message): self.id = message.id self.created_at = message.created_at diff --git a/src/main.py b/src/main.py index e128333..2f73357 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,11 @@ +import sys from miniscord import Bot import logging +if sys.version_info < (3, 7): + print("Please upgrade your Python version to 3.7.0 or higher") + sys.exit(1) + from utils import emojis from scanners import ( EmotesScanner, @@ -13,6 +18,9 @@ from scanners import ( MessagesScanner, ChannelsScanner, ReactionsScanner, + FirstScanner, + RandomScanner, + LastScanner, ) from logs import GuildLogs @@ -24,7 +32,7 @@ emojis.load_emojis() bot = Bot( "Discord Analyst", - "1.10", + "1.11", alias="%", ) @@ -36,6 +44,24 @@ bot.register_command( "cancel: stop current analysis", "```\n" + "%cancel: Stop current analysis\n" + "```", ) +bot.register_command( + "last", + lambda *args: LastScanner().compute(*args), + "last: read last message", + LastScanner.help(), +) +bot.register_command( + "rand(om)?", + lambda *args: RandomScanner().compute(*args), + "rand: read a random message", + RandomScanner.help(), +) +bot.register_command( + "first", + lambda *args: FirstScanner().compute(*args), + "first: read first message", + FirstScanner.help(), +) bot.register_command( "mentioned", lambda *args: MentionedScanner().compute(*args), diff --git a/src/scanners/__init__.py b/src/scanners/__init__.py index 510c886..37eaa77 100644 --- a/src/scanners/__init__.py +++ b/src/scanners/__init__.py @@ -8,3 +8,6 @@ from .mentioned_scanner import MentionedScanner from .messages_scanner import MessagesScanner from .channels_scanner import ChannelsScanner from .reactions_scanner import ReactionsScanner +from .first_scanner import FirstScanner +from .last_scanner import LastScanner +from .random_scanner import RandomScanner \ No newline at end of file diff --git a/src/scanners/first_scanner.py b/src/scanners/first_scanner.py new file mode 100644 index 0000000..766b145 --- /dev/null +++ b/src/scanners/first_scanner.py @@ -0,0 +1,19 @@ +from typing import List + +# Custom libs + +from .history_scanner import HistoryScanner + + +class FirstScanner(HistoryScanner): + @staticmethod + def help() -> str: + return super(FirstScanner, FirstScanner).help( + cmd="first", text="Read first message" + ) + + def __init__(self): + super().__init__(help=FirstScanner.help()) + + def get_results(self, intro: str) -> List[str]: + return self.history.to_string(type="first") diff --git a/src/scanners/frequency_scanner.py b/src/scanners/frequency_scanner.py index 348be1b..fac0a27 100644 --- a/src/scanners/frequency_scanner.py +++ b/src/scanners/frequency_scanner.py @@ -44,7 +44,9 @@ class FrequencyScanner(Scanner): def get_results(self, intro: str) -> List[str]: FrequencyScanner.compute_results(self.freq) res = [intro] - res += self.freq.to_string() + res += self.freq.to_string( + member_specific=self.member_specific, + ) return res @staticmethod @@ -64,6 +66,20 @@ class FrequencyScanner(Scanner): ): impacted = True freq.dates += [message.created_at] + if message.author == freq.last_author: + freq.streaks[-1] += 1 + else: + if len(freq.streaks) > 0 and ( + freq.longest_streak is None + or freq.streaks[-1] > freq.longest_streak + ): + freq.longest_streak = freq.streaks[-1] + freq.longest_streak_start = freq.last_streak_start + freq.longest_streak_author = freq.last_streak_author + freq.streaks += [1] + freq.last_streak_start = message.created_at + freq.last_streak_author = message.author + freq.last_author = message.author return impacted @staticmethod diff --git a/src/scanners/full_scanner.py b/src/scanners/full_scanner.py index 90c0b09..22149bd 100644 --- a/src/scanners/full_scanner.py +++ b/src/scanners/full_scanner.py @@ -63,7 +63,7 @@ class FullScanner(Scanner): FrequencyScanner.compute_results(self.freq) res = [intro] res += ["__Frequency__:"] - res += self.freq.to_string() + res += self.freq.to_string(member_specific=self.member_specific) res += ["__Composition__:"] res += self.compo.to_string(self.msg_count) res += ["__Presence__:"] diff --git a/src/scanners/history_scanner.py b/src/scanners/history_scanner.py new file mode 100644 index 0000000..c61872e --- /dev/null +++ b/src/scanners/history_scanner.py @@ -0,0 +1,70 @@ +from abc import ABC, abstractmethod +from typing import List +import discord + +# Custom libs + +from .scanner import Scanner +from data_types import History +from logs import ChannelLogs, MessageLog +from utils import COMMON_HELP_ARGS + + +class HistoryScanner(Scanner, ABC): + @staticmethod + def help(*, cmd: str, text: str) -> str: + return ( + "```\n" + + f"%{cmd}: {text}\n" + + "arguments:\n" + + COMMON_HELP_ARGS + + "* all/everyone - include bots\n" + + "Example: %{cmd} #mychannel1 @user\n" + + "```" + ) + + def __init__(self, *, help: str): + super().__init__( + has_digit_args=True, + valid_args=["all", "everyone"], + help=help, + intro_context="", + ) + + async def init(self, message: discord.Message, *args: str) -> bool: + self.history = History() + self.all_messages = "all" in args or "everyone" in args + return True + + def compute_message(self, channel: ChannelLogs, message: MessageLog): + return HistoryScanner.analyse_message( + channel, + message, + self.history, + self.raw_members, + all_messages=self.all_messages, + ) + + @abstractmethod + def get_results(self, intro: str): + pass + + @staticmethod + def analyse_message( + channel: ChannelLogs, + message: MessageLog, + history: History, + raw_members: List[int], + *, + all_messages: bool, + ) -> 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): + impacted = True + history.messages += [message] + return impacted diff --git a/src/scanners/last_scanner.py b/src/scanners/last_scanner.py new file mode 100644 index 0000000..7713195 --- /dev/null +++ b/src/scanners/last_scanner.py @@ -0,0 +1,19 @@ +from typing import List + +# Custom libs + +from .history_scanner import HistoryScanner + + +class LastScanner(HistoryScanner): + @staticmethod + def help() -> str: + return super(LastScanner, LastScanner).help( + cmd="last", text="Read last message" + ) + + def __init__(self): + super().__init__(help=LastScanner.help()) + + 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 new file mode 100644 index 0000000..9ef520b --- /dev/null +++ b/src/scanners/random_scanner.py @@ -0,0 +1,19 @@ +from typing import List + +# Custom libs + +from .history_scanner import HistoryScanner + + +class RandomScanner(HistoryScanner): + @staticmethod + def help() -> str: + return super(RandomScanner, RandomScanner).help( + cmd="rand", text="Read a random message" + ) + + def __init__(self): + super().__init__(help=RandomScanner.help()) + + def get_results(self, intro: str) -> List[str]: + return self.history.to_string(type="random") diff --git a/src/scanners/scanner.py b/src/scanners/scanner.py index 079217b..4a3b749 100644 --- a/src/scanners/scanner.py +++ b/src/scanners/scanner.py @@ -5,7 +5,7 @@ import logging import re import discord -from utils import no_duplicate, get_intro, delta, deltas, mention, channel_mention +from utils import no_duplicate, get_intro, delta from logs import GuildLogs, ChannelLogs, MessageLog, ALREADY_RUNNING, CANCELLED @@ -173,5 +173,5 @@ class Scanner(ABC): pass @abstractmethod - def get_results(self, intro: str): + def get_results(self, intro: str) -> List[str]: pass diff --git a/src/utils/utils.py b/src/utils/utils.py index e32e4a4..880d892 100644 --- a/src/utils/utils.py +++ b/src/utils/utils.py @@ -51,6 +51,10 @@ def channel_mention(channel_id: int) -> str: return f"<#{channel_id}>" +def message_link(message: discord.Message) -> str: + return f"https://discord.com/channels/{message.channel.guild.id}/{message.channel.id}/{message.id}" + + class FakeMessage: def __init__(self, id: int): self.id = id