2024-03-18 23:57:13 +00:00
|
|
|
import asyncio
|
2024-04-15 19:50:17 +01:00
|
|
|
import logging
|
2024-03-18 23:57:13 +00:00
|
|
|
import re
|
|
|
|
|
|
|
|
import discord
|
|
|
|
import io
|
|
|
|
import matplotlib.pyplot as plt
|
2024-04-15 18:24:55 +01:00
|
|
|
from datetime import timedelta, datetime
|
2024-03-18 23:57:13 +00:00
|
|
|
from discord.ext import commands
|
2024-04-15 19:47:58 +01:00
|
|
|
from typing import Callable, Iterable, Annotated
|
2024-03-18 23:57:13 +00:00
|
|
|
|
|
|
|
from conf import CONFIG
|
|
|
|
|
|
|
|
|
|
|
|
class QuoteQuota(commands.Cog):
|
2024-03-19 00:20:22 +00:00
|
|
|
|
2024-03-18 23:57:13 +00:00
|
|
|
def __init__(self, bot):
|
|
|
|
self.bot = bot
|
|
|
|
self.quotes_channel_id = CONFIG["quote_a"].get("channel_id")
|
2024-03-19 00:25:45 +00:00
|
|
|
self.names = CONFIG["quote_a"].get("names", {})
|
2024-04-15 19:50:17 +01:00
|
|
|
self.log = logging.getLogger("jimmy.cogs.quote_quota")
|
2024-03-18 23:57:13 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def quotes_channel(self) -> discord.TextChannel | None:
|
|
|
|
if self.quotes_channel_id:
|
|
|
|
c = self.bot.get_channel(self.quotes_channel_id)
|
|
|
|
if c:
|
|
|
|
return c
|
|
|
|
|
|
|
|
@staticmethod
|
2024-04-16 00:46:26 +01:00
|
|
|
def generate_pie_chart(usernames: list[str], counts: list[int], no_other: bool = False) -> discord.File:
|
2024-03-18 23:57:13 +00:00
|
|
|
"""
|
|
|
|
Converts the given username and count tuples into a nice pretty pie chart.
|
|
|
|
|
|
|
|
:param usernames: The usernames
|
|
|
|
:param counts: The number of times the username appears in the chat
|
2024-03-19 00:25:45 +00:00
|
|
|
:param no_other: Disables the "other" grouping
|
2024-03-18 23:57:13 +00:00
|
|
|
:returns: The pie chart image
|
|
|
|
"""
|
2024-03-19 00:14:50 +00:00
|
|
|
|
|
|
|
def pct(v: int):
|
2024-03-19 00:25:45 +00:00
|
|
|
return f"{v:.1f}% ({round((v / 100) * sum(counts))})"
|
|
|
|
|
|
|
|
if no_other is False:
|
|
|
|
other = []
|
|
|
|
# Any authors with less than 5% of the total count will be grouped into "other"
|
|
|
|
for i, author in enumerate(usernames.copy()):
|
|
|
|
if (c := counts[i]) / sum(counts) < 0.05:
|
|
|
|
other.append(c)
|
|
|
|
counts[i] = -1
|
|
|
|
usernames.remove(author)
|
|
|
|
if other:
|
|
|
|
usernames.append("Other")
|
|
|
|
counts.append(sum(other))
|
|
|
|
# And now filter out any -1% counts
|
|
|
|
counts = [c for c in counts if c != -1]
|
2024-03-19 00:20:22 +00:00
|
|
|
|
2024-03-19 00:48:24 +00:00
|
|
|
mapping = {}
|
|
|
|
for i, author in enumerate(usernames):
|
|
|
|
mapping[author] = counts[i]
|
|
|
|
|
|
|
|
# Sort the authors by count
|
|
|
|
new_mapping = {}
|
|
|
|
for author, count in sorted(mapping.items(), key=lambda x: x[1], reverse=True):
|
|
|
|
new_mapping[author] = count
|
|
|
|
|
|
|
|
usernames = list(new_mapping.keys())
|
|
|
|
counts = list(new_mapping.values())
|
|
|
|
|
2024-04-16 13:14:31 +01:00
|
|
|
plt.clf()
|
2024-03-19 00:32:50 +00:00
|
|
|
fig, ax = plt.subplots(figsize=(7, 7))
|
2024-03-18 23:57:13 +00:00
|
|
|
ax.pie(
|
|
|
|
counts,
|
|
|
|
labels=usernames,
|
2024-03-19 00:14:50 +00:00
|
|
|
autopct=pct,
|
2024-03-19 00:25:45 +00:00
|
|
|
startangle=90,
|
2024-03-19 00:39:55 +00:00
|
|
|
radius=1.2,
|
2024-03-18 23:57:13 +00:00
|
|
|
)
|
2024-03-19 00:39:55 +00:00
|
|
|
fig.subplots_adjust(left=0.1, bottom=0.1, right=0.9, top=0.9, wspace=0.3, hspace=0.4)
|
2024-03-18 23:57:13 +00:00
|
|
|
fio = io.BytesIO()
|
2024-04-16 00:46:26 +01:00
|
|
|
fig.savefig(fio, format="png")
|
2024-03-18 23:57:13 +00:00
|
|
|
fio.seek(0)
|
2024-03-19 00:32:20 +00:00
|
|
|
return discord.File(fio, filename="pie.png")
|
2024-03-18 23:57:13 +00:00
|
|
|
|
|
|
|
@commands.slash_command()
|
|
|
|
async def quota(
|
2024-04-16 00:46:26 +01:00
|
|
|
self,
|
|
|
|
ctx: discord.ApplicationContext,
|
|
|
|
days: Annotated[
|
|
|
|
int,
|
|
|
|
discord.Option(
|
2024-03-18 23:57:13 +00:00
|
|
|
int,
|
2024-04-16 00:46:26 +01:00
|
|
|
name="lookback",
|
|
|
|
description="How many days to look back on. Defaults to 7.",
|
|
|
|
default=7,
|
|
|
|
min_value=1,
|
|
|
|
max_value=365,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
merge_other: Annotated[
|
|
|
|
bool,
|
|
|
|
discord.Option(
|
2024-03-19 00:25:45 +00:00
|
|
|
bool,
|
2024-04-16 00:46:26 +01:00
|
|
|
name="merge_other",
|
|
|
|
description="Whether to merge authors with less than 5% of the total count into 'Other'.",
|
|
|
|
default=True,
|
|
|
|
),
|
|
|
|
],
|
2024-03-18 23:57:13 +00:00
|
|
|
):
|
|
|
|
"""Checks the quote quota for the quotes channel."""
|
|
|
|
now = discord.utils.utcnow()
|
2024-03-18 23:59:46 +00:00
|
|
|
oldest = now - timedelta(days=days)
|
2024-03-18 23:57:13 +00:00
|
|
|
await ctx.defer()
|
2024-03-18 23:59:46 +00:00
|
|
|
channel = self.quotes_channel or discord.utils.get(ctx.guild.text_channels, name="quotes")
|
2024-03-18 23:57:13 +00:00
|
|
|
if not channel:
|
|
|
|
return await ctx.respond(":x: Cannot find quotes channel.")
|
|
|
|
|
|
|
|
await ctx.respond("Gathering messages, this may take a moment.")
|
|
|
|
|
|
|
|
authors = {}
|
|
|
|
filtered_messages = 0
|
|
|
|
total = 0
|
2024-04-16 00:46:26 +01:00
|
|
|
async for message in channel.history(limit=None, after=oldest, oldest_first=False):
|
2024-03-18 23:57:13 +00:00
|
|
|
total += 1
|
|
|
|
if not message.content:
|
|
|
|
filtered_messages += 1
|
|
|
|
continue
|
|
|
|
if message.attachments:
|
2024-03-19 00:37:06 +00:00
|
|
|
regex = r".*\s*-\s*@?([\w\s]+)"
|
2024-03-18 23:57:13 +00:00
|
|
|
else:
|
2024-03-19 00:37:06 +00:00
|
|
|
regex = r".+\s+-\s*@?([\w\s]+)"
|
2024-03-18 23:57:13 +00:00
|
|
|
|
2024-03-19 00:06:12 +00:00
|
|
|
if not (m := re.match(regex, str(message.clean_content))):
|
2024-03-18 23:57:13 +00:00
|
|
|
filtered_messages += 1
|
|
|
|
continue
|
|
|
|
name = m.group(1)
|
2024-03-19 00:43:23 +00:00
|
|
|
name = name.strip().casefold()
|
|
|
|
if name == "me":
|
2024-03-19 00:25:45 +00:00
|
|
|
name = message.author.name.strip().casefold()
|
|
|
|
if name in self.names:
|
2024-03-19 00:42:04 +00:00
|
|
|
name = self.names[name].title()
|
2024-03-19 00:25:45 +00:00
|
|
|
else:
|
|
|
|
filtered_messages += 1
|
|
|
|
continue
|
2024-03-19 00:43:23 +00:00
|
|
|
elif name in self.names:
|
2024-03-19 00:42:04 +00:00
|
|
|
name = self.names[name].title()
|
2024-03-19 00:37:06 +00:00
|
|
|
elif name.isdigit():
|
|
|
|
filtered_messages += 1
|
|
|
|
continue
|
2024-03-19 00:25:45 +00:00
|
|
|
|
2024-03-19 00:43:23 +00:00
|
|
|
name = name.title()
|
2024-03-18 23:57:13 +00:00
|
|
|
authors.setdefault(name, 0)
|
|
|
|
authors[name] += 1
|
|
|
|
|
2024-03-19 00:46:10 +00:00
|
|
|
if not authors:
|
|
|
|
if total:
|
|
|
|
return await ctx.edit(
|
|
|
|
content="No valid messages found in the last {!s} days. "
|
2024-04-16 00:46:26 +01:00
|
|
|
"Make sure quotes are formatted properly ending with ` - AuthorName`"
|
|
|
|
' (e.g. `"This is my quote" - Jimmy`)'.format(days)
|
2024-03-19 00:46:10 +00:00
|
|
|
)
|
|
|
|
else:
|
2024-04-16 00:46:26 +01:00
|
|
|
return await ctx.edit(content="No messages found in the last {!s} days.".format(days))
|
2024-03-19 00:46:10 +00:00
|
|
|
|
2024-03-18 23:57:13 +00:00
|
|
|
file = await asyncio.to_thread(
|
2024-04-16 00:46:26 +01:00
|
|
|
self.generate_pie_chart, list(authors.keys()), list(authors.values()), merge_other
|
2024-03-18 23:57:13 +00:00
|
|
|
)
|
|
|
|
return await ctx.edit(
|
|
|
|
content="{:,} messages (out of {:,}) were filtered (didn't follow format?)".format(
|
2024-04-16 00:46:26 +01:00
|
|
|
filtered_messages, total
|
2024-03-18 23:57:13 +00:00
|
|
|
),
|
2024-04-16 00:46:26 +01:00
|
|
|
file=file,
|
2024-03-18 23:57:13 +00:00
|
|
|
)
|
|
|
|
|
2024-04-15 19:47:58 +01:00
|
|
|
def _metacounter(
|
2024-04-16 00:46:26 +01:00
|
|
|
self, messages: list[discord.Message], filter_func: Callable[[discord.Message], bool], *, now: datetime = None
|
2024-04-16 12:03:58 +01:00
|
|
|
) -> dict[str, float | int | dict[str, int]]:
|
2024-04-16 11:40:13 +01:00
|
|
|
def _is_today(date: datetime) -> bool:
|
|
|
|
return date.date() == now.date()
|
|
|
|
|
2024-04-15 19:47:58 +01:00
|
|
|
now = now or discord.utils.utcnow().replace(minute=0, second=0, microsecond=0)
|
|
|
|
counts = {
|
|
|
|
"hour": 0,
|
|
|
|
"day": 0,
|
2024-04-16 11:40:13 +01:00
|
|
|
"today": 0,
|
2024-04-15 19:47:58 +01:00
|
|
|
"week": 0,
|
|
|
|
"all_time": 0,
|
|
|
|
"per_minute": 0.0,
|
|
|
|
"per_hour": 0.0,
|
|
|
|
"per_day": 0.0,
|
2024-04-16 12:03:58 +01:00
|
|
|
"hours": {}
|
2024-04-15 19:47:58 +01:00
|
|
|
}
|
2024-04-16 12:03:58 +01:00
|
|
|
for i in range(24):
|
|
|
|
counts["hours"][str(i)] = 0
|
2024-04-15 19:47:58 +01:00
|
|
|
for message in messages:
|
|
|
|
if filter_func(message):
|
2024-04-15 19:50:17 +01:00
|
|
|
age = now - message.created_at
|
2024-04-15 19:55:42 +01:00
|
|
|
self.log.debug("%r was a truth (%.2f seconds ago).", message.id, age.total_seconds())
|
2024-04-15 19:47:58 +01:00
|
|
|
counts["all_time"] += 1
|
2024-04-16 11:40:13 +01:00
|
|
|
if _is_today(message.created_at):
|
|
|
|
counts["today"] += 1
|
2024-04-15 19:47:58 +01:00
|
|
|
if message.created_at > now - timedelta(hours=1):
|
|
|
|
counts["hour"] += 1
|
|
|
|
if message.created_at > now - timedelta(days=1):
|
|
|
|
counts["day"] += 1
|
|
|
|
if message.created_at > now - timedelta(days=7):
|
|
|
|
counts["week"] += 1
|
2024-04-16 12:03:58 +01:00
|
|
|
counts["hours"][str(message.created_at.hour)] += 1
|
2024-04-15 19:47:58 +01:00
|
|
|
|
|
|
|
counts["per_minute"] = counts["hour"] / 60
|
|
|
|
counts["per_hour"] = counts["day"] / 24
|
|
|
|
counts["per_day"] = counts["week"] / 7
|
2024-04-15 19:50:17 +01:00
|
|
|
self.log.info("Total truth counts: %r", counts)
|
2024-04-15 19:47:58 +01:00
|
|
|
return counts
|
|
|
|
|
2024-04-16 12:03:58 +01:00
|
|
|
async def _process_trump_truths(self, messages: list[discord.Message]):
|
2024-04-15 19:44:49 +01:00
|
|
|
"""
|
|
|
|
Processes the given messages to count the number of posts by Donald Trump.
|
2024-04-15 18:32:17 +01:00
|
|
|
|
2024-04-15 19:44:49 +01:00
|
|
|
:param messages: The messages to process
|
|
|
|
:returns: The stats
|
|
|
|
"""
|
2024-04-16 00:46:26 +01:00
|
|
|
|
2024-04-15 18:32:17 +01:00
|
|
|
def is_truth(msg: discord.Message) -> bool:
|
|
|
|
if msg.author.id == 1101439218334576742:
|
2024-04-15 21:15:12 +01:00
|
|
|
# if msg.created_at.timestamp() <= 1713202855.80234:
|
|
|
|
# # Pre 6:30pm 15th April, embeds were not tagged for detection.
|
|
|
|
# truth = any((_te.type == "rich" for _te in msg.embeds))
|
|
|
|
# if truth:
|
|
|
|
# self.log.debug("Found untagged rich trump truth embed: %r", msg.id)
|
|
|
|
# return True
|
2024-04-15 18:32:17 +01:00
|
|
|
for __t_e in msg.embeds:
|
2024-04-15 21:16:42 +01:00
|
|
|
if __t_e.type == "rich" and __t_e.colour is not None and __t_e.colour.value == 0x5448EE:
|
2024-04-15 19:55:42 +01:00
|
|
|
self.log.debug("Found tagged rich trump truth embed: %r", msg.id)
|
2024-04-15 18:32:17 +01:00
|
|
|
return True
|
|
|
|
return False
|
|
|
|
|
2024-04-15 19:47:58 +01:00
|
|
|
return self._metacounter(messages, is_truth)
|
2024-04-15 19:44:49 +01:00
|
|
|
|
2024-04-16 12:03:58 +01:00
|
|
|
async def _process_tate_truths(self, messages: list[discord.Message]):
|
2024-04-15 19:44:49 +01:00
|
|
|
"""
|
|
|
|
Processes the given messages to count the number of posts by Andrew Tate.
|
|
|
|
|
|
|
|
:param messages: The messages to process
|
|
|
|
:returns: The stats
|
|
|
|
"""
|
2024-04-16 00:46:26 +01:00
|
|
|
|
2024-04-15 19:44:49 +01:00
|
|
|
def is_truth(msg: discord.Message) -> bool:
|
|
|
|
if msg.author.id == 1229496078726860921:
|
|
|
|
# All the tate truths are already tagged.
|
|
|
|
for __t_e in msg.embeds:
|
|
|
|
if __t_e.type == "rich" and __t_e.colour.value == 0x5448EE:
|
2024-04-15 19:55:42 +01:00
|
|
|
self.log.debug("Found tagged rich tate truth embed %r", __t_e)
|
2024-04-15 19:44:49 +01:00
|
|
|
return True
|
|
|
|
return False
|
|
|
|
|
2024-04-15 19:47:58 +01:00
|
|
|
return self._metacounter(messages, is_truth)
|
2024-04-15 19:44:49 +01:00
|
|
|
|
2024-04-16 12:03:58 +01:00
|
|
|
@staticmethod
|
|
|
|
def _generate_truth_frequency_graph(hours: dict[str, int]) -> discord.File:
|
|
|
|
"""
|
|
|
|
Generates a bar chart representing the most truthed times.
|
|
|
|
|
|
|
|
:param hours: A dictionary of {"hour": count}
|
|
|
|
:return: A discord.File with the rendered graph
|
|
|
|
"""
|
|
|
|
hrs = [x.zfill(2) for x in hours.keys()]
|
2024-04-16 13:14:31 +01:00
|
|
|
plt.clf()
|
2024-04-16 12:03:58 +01:00
|
|
|
plt.title("Truth times")
|
|
|
|
plt.xlabel("Hour")
|
|
|
|
plt.ylabel("Truths")
|
|
|
|
plt.bar(hrs, list(hours.values()), color="#5448EE")
|
|
|
|
|
|
|
|
file = io.BytesIO()
|
|
|
|
plt.savefig(file, format="jpg")
|
|
|
|
file.seek(0)
|
2024-04-16 12:06:11 +01:00
|
|
|
return discord.File(file, "truths.png")
|
2024-04-16 12:03:58 +01:00
|
|
|
|
|
|
|
async def _process_all_messages(self, channel: discord.TextChannel) -> tuple[discord.Embed, discord.File]:
|
2024-04-15 19:44:49 +01:00
|
|
|
"""
|
|
|
|
Processes all the messages in the given channel.
|
|
|
|
|
|
|
|
:param channel: The channel to process
|
|
|
|
:returns: The stats
|
|
|
|
"""
|
2024-04-16 00:46:26 +01:00
|
|
|
embed = discord.Embed(title="Truth Counts", color=discord.Color.blurple(), timestamp=discord.utils.utcnow())
|
2024-04-15 19:55:21 +01:00
|
|
|
messages: list[discord.Message] = await channel.history(
|
2024-04-16 00:46:26 +01:00
|
|
|
limit=None, after=discord.Object(1229487065117233203)
|
2024-04-15 19:55:21 +01:00
|
|
|
).flatten()
|
2024-04-15 19:44:49 +01:00
|
|
|
trump_stats = await self._process_trump_truths(messages)
|
|
|
|
tate_stats = await self._process_tate_truths(messages)
|
|
|
|
|
|
|
|
embed.add_field(
|
|
|
|
name="Donald Trump",
|
|
|
|
value=(
|
|
|
|
f"**All time:** {trump_stats['all_time']:,}\n"
|
2024-04-16 11:40:13 +01:00
|
|
|
f"**Today:** {trump_stats['today']:,}\n"
|
2024-04-16 11:41:55 +01:00
|
|
|
f"**Last Week:** {trump_stats['week']:,} ({trump_stats['per_day']:.1f}/day)\n"
|
2024-04-16 11:40:13 +01:00
|
|
|
f"**Last 24 Hours:** {trump_stats['day']:,} ({trump_stats['per_hour']:.1f}/hour)\n"
|
2024-04-15 19:44:49 +01:00
|
|
|
f"**Last Hour:** {trump_stats['hour']:,} ({trump_stats['per_minute']:.1f}/min)"
|
|
|
|
),
|
|
|
|
)
|
|
|
|
embed.add_field(
|
|
|
|
name="Andrew Tate",
|
|
|
|
value=(
|
|
|
|
f"**All time:** {tate_stats['all_time']:,}\n"
|
2024-04-16 11:40:13 +01:00
|
|
|
f"**Today:** {tate_stats['today']:,}\n"
|
2024-04-16 11:41:55 +01:00
|
|
|
f"**Last Week:** {tate_stats['week']:,} ({tate_stats['per_day']:.1f}/day)\n"
|
2024-04-16 11:40:13 +01:00
|
|
|
f"**Last 24 Hours:** {tate_stats['day']:,} ({tate_stats['per_hour']:.1f}/hour)\n"
|
2024-04-15 19:44:49 +01:00
|
|
|
f"**Last Hour:** {tate_stats['hour']:,} ({tate_stats['per_minute']:.1f}/min)"
|
2024-04-16 00:46:26 +01:00
|
|
|
),
|
2024-04-15 19:44:49 +01:00
|
|
|
)
|
2024-04-16 12:03:58 +01:00
|
|
|
hours = trump_stats["hours"].copy()
|
|
|
|
for stat_k, stat_v in tate_stats["hours"].items():
|
|
|
|
hours[stat_k] += stat_v
|
|
|
|
graph = await asyncio.to_thread(self._generate_truth_frequency_graph, hours)
|
2024-04-16 12:06:11 +01:00
|
|
|
embed.set_image(url="attachment://truths.png")
|
2024-04-16 12:03:58 +01:00
|
|
|
return embed, graph
|
2024-04-15 19:44:49 +01:00
|
|
|
|
|
|
|
@commands.slash_command(name="truths")
|
|
|
|
async def truths_counter(self, ctx: discord.ApplicationContext):
|
|
|
|
"""Counts the number of times the word 'truth' has been said in the quotes channel."""
|
2024-04-15 18:24:55 +01:00
|
|
|
channel = discord.utils.get(ctx.guild.text_channels, name="spam")
|
|
|
|
if not channel:
|
|
|
|
return await ctx.respond(":x: Cannot find spam channel.")
|
|
|
|
|
|
|
|
await ctx.defer()
|
|
|
|
now = discord.utils.utcnow().replace(minute=0, second=0, microsecond=0)
|
|
|
|
|
|
|
|
embed = discord.Embed(
|
|
|
|
title="Counting truths, please wait.",
|
2024-04-15 19:44:49 +01:00
|
|
|
description="This may take a minute depending on how insane Trump and Tate are feeling.",
|
2024-04-15 18:24:55 +01:00
|
|
|
color=discord.Color.blurple(),
|
2024-04-16 00:46:26 +01:00
|
|
|
timestamp=now,
|
2024-04-15 18:24:55 +01:00
|
|
|
)
|
|
|
|
await ctx.respond(embed=embed)
|
2024-04-16 12:03:58 +01:00
|
|
|
embed, file = await self._process_all_messages(channel)
|
|
|
|
await ctx.edit(embed=embed, file=file)
|
2024-04-15 18:24:55 +01:00
|
|
|
|
2024-03-18 23:57:13 +00:00
|
|
|
|
|
|
|
def setup(bot):
|
|
|
|
bot.add_cog(QuoteQuota(bot))
|