From c0f0da9262ff8ed1368db27e9f8d5479a831ecaf Mon Sep 17 00:00:00 2001 From: Fluffyscratch Date: Sat, 19 Sep 2026 17:44:27 +0200 Subject: [PATCH 1/6] Add live Scratch/Turbowarp usage stats backend --- config.py | 12 +++++++++- events/bot_events.py | 53 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/config.py b/config.py index 13b3fdc..2c11542 100644 --- a/config.py +++ b/config.py @@ -48,4 +48,14 @@ pending_verifiers: dict = {} # Variable to indicate if the bot is ready -bot_ready = False \ No newline at end of file +bot_ready = False + +# Activity statistics +activity_stats = { + "turbowarp": 0, + "streaming_turbowarp": 0, + "scratch3": 0, + "streaming_scratch3": 0, + "scratch2": 0, + "scratch1": 0, +} \ No newline at end of file diff --git a/events/bot_events.py b/events/bot_events.py index 5be658b..723fdae 100644 --- a/events/bot_events.py +++ b/events/bot_events.py @@ -2,11 +2,12 @@ Discord bot events. """ +from re import match import interactions import logging from interactions.api.events import CommandError -from config import bot, bot_statuses, button_states, bot_ready +from config import bot, bot_statuses, button_states, bot_ready, activity_stats from database import add_server @@ -45,6 +46,56 @@ async def on_guild_join(self, event: interactions.events.GuildJoin): if bot_ready: add_server(event.guild.id) + @interactions.listen(interactions.events.MemberUpdate) + async def on_member_update(self, before, after): + """Handle member rich presence update events.""" + if before.activities != after.activities: + # Handle activity changes to update stats + for newactivity, oldactivity in zip(after.activities, before.activities): + # Handle new activities + if newactivity.name == "TurboWarp Desktop": + if newactivity.type == interactions.ActivityType.PLAYING: + activity_stats["turbowarp"] += 1 + elif newactivity.type == interactions.ActivityType.STREAMING: + activity_stats["streaming_turbowarp"] += 1 + elif match(r"Scratch 3\.\d+\.\d+", newactivity.name): + if newactivity.type == interactions.ActivityType.PLAYING: + activity_stats["scratch3"] += 1 + elif newactivity.type == interactions.ActivityType.STREAMING: + activity_stats["streaming_scratch3"] += 1 + elif ( + newactivity.name == "Scratch 2 Offline Editor" + and newactivity.type == interactions.ActivityType.PLAYING + ): + activity_stats["scratch2"] += 1 + elif ( + match(r"Scratch 1\.4 of \d{2}-[A-Za-z]{3}-\d{2}", newactivity.name) + and newactivity.type == interactions.ActivityType.PLAYING + ): + activity_stats["scratch1"] += 1 + + # Handle removed activities + if oldactivity.name == "TurboWarp Desktop": + if oldactivity.type == interactions.ActivityType.PLAYING: + activity_stats["turbowarp"] -= 1 + elif oldactivity.type == interactions.ActivityType.STREAMING: + activity_stats["streaming_turbowarp"] -= 1 + elif match(r"Scratch 3\.\d+\.\d+", oldactivity.name): + if oldactivity.type == interactions.ActivityType.PLAYING: + activity_stats["scratch3"] -= 1 + elif oldactivity.type == interactions.ActivityType.STREAMING: + activity_stats["streaming_scratch3"] -= 1 + elif ( + oldactivity.name == "Scratch 2 Offline Editor" + and oldactivity.type == interactions.ActivityType.PLAYING + ): + activity_stats["scratch2"] -= 1 + elif ( + match(r"Scratch 1\.4 of \d{2}-[A-Za-z]{3}-\d{2}", oldactivity.name) + and oldactivity.type == interactions.ActivityType.PLAYING + ): + activity_stats["scratch1"] -= 1 + # ------------------------------------------------------------------ # # Component interaction handler (settings buttons) # # ------------------------------------------------------------------ # From 8215a2def75d4178b8314eb2d8147540bc63c7a0 Mon Sep 17 00:00:00 2001 From: Fluffyscratch Date: Sat, 19 Sep 2026 18:01:08 +0200 Subject: [PATCH 2/6] Add /community --- commands/utility_commands.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/commands/utility_commands.py b/commands/utility_commands.py index 735d657..aa00982 100644 --- a/commands/utility_commands.py +++ b/commands/utility_commands.py @@ -11,6 +11,7 @@ import scratchattach as scratch +import config from config import scratch_orange @@ -316,6 +317,27 @@ async def scratchblocks( filename = await render_blocks_image(code=code, style=style) await ctx.send(file=interactions.File(file=filename)) + @interactions.slash_command( + name="community", + description="Check what the Scratch community is up to.", + ) + async def community(self, ctx: interactions.SlashContext): + stats = config.activity_stats + await ctx.send( + embed=interactions.Embed( + title=":eyes: What the community is up to:", + description=( + f"**{stats['scratch3']}** people are using Scratch 3\n" + f"- **{stats['streaming_scratch3']}** people are streaming it\n" + f"**{stats['turbowarp']}** people are using TurboWarp Desktop\n" + f"- **{stats['streaming_turbowarp']}** people are streaming it\n" + f"**{stats['scratch2']}** people are using Scratch 2\n" + f"**{stats['scratch1']}** people are using Scratch 1.4\n" + ), + color=scratch_orange, + ) + ) + def setup(bot: interactions.Client): UtilityCommands(bot) From cfbb24bbe40fb4d54bf749898e785591ab3255a2 Mon Sep 17 00:00:00 2001 From: Fluffyscratch Date: Sat, 19 Sep 2026 18:27:53 +0200 Subject: [PATCH 3/6] Make activity stats shared between bot events and utility commands --- events/bot_events.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/events/bot_events.py b/events/bot_events.py index 723fdae..67e9e28 100644 --- a/events/bot_events.py +++ b/events/bot_events.py @@ -7,7 +7,8 @@ import logging from interactions.api.events import CommandError -from config import bot, bot_statuses, button_states, bot_ready, activity_stats +import config +from config import bot, bot_statuses, button_states, bot_ready from database import add_server @@ -51,50 +52,51 @@ async def on_member_update(self, before, after): """Handle member rich presence update events.""" if before.activities != after.activities: # Handle activity changes to update stats + stats = config.activity_stats for newactivity, oldactivity in zip(after.activities, before.activities): # Handle new activities if newactivity.name == "TurboWarp Desktop": if newactivity.type == interactions.ActivityType.PLAYING: - activity_stats["turbowarp"] += 1 + stats["turbowarp"] += 1 elif newactivity.type == interactions.ActivityType.STREAMING: - activity_stats["streaming_turbowarp"] += 1 + stats["streaming_turbowarp"] += 1 elif match(r"Scratch 3\.\d+\.\d+", newactivity.name): if newactivity.type == interactions.ActivityType.PLAYING: - activity_stats["scratch3"] += 1 + stats["scratch3"] += 1 elif newactivity.type == interactions.ActivityType.STREAMING: - activity_stats["streaming_scratch3"] += 1 + stats["streaming_scratch3"] += 1 elif ( newactivity.name == "Scratch 2 Offline Editor" and newactivity.type == interactions.ActivityType.PLAYING ): - activity_stats["scratch2"] += 1 + stats["scratch2"] += 1 elif ( match(r"Scratch 1\.4 of \d{2}-[A-Za-z]{3}-\d{2}", newactivity.name) and newactivity.type == interactions.ActivityType.PLAYING ): - activity_stats["scratch1"] += 1 + stats["scratch1"] += 1 # Handle removed activities if oldactivity.name == "TurboWarp Desktop": if oldactivity.type == interactions.ActivityType.PLAYING: - activity_stats["turbowarp"] -= 1 + stats["turbowarp"] -= 1 elif oldactivity.type == interactions.ActivityType.STREAMING: - activity_stats["streaming_turbowarp"] -= 1 + stats["streaming_turbowarp"] -= 1 elif match(r"Scratch 3\.\d+\.\d+", oldactivity.name): if oldactivity.type == interactions.ActivityType.PLAYING: - activity_stats["scratch3"] -= 1 + stats["scratch3"] -= 1 elif oldactivity.type == interactions.ActivityType.STREAMING: - activity_stats["streaming_scratch3"] -= 1 + stats["streaming_scratch3"] -= 1 elif ( oldactivity.name == "Scratch 2 Offline Editor" and oldactivity.type == interactions.ActivityType.PLAYING ): - activity_stats["scratch2"] -= 1 + stats["scratch2"] -= 1 elif ( match(r"Scratch 1\.4 of \d{2}-[A-Za-z]{3}-\d{2}", oldactivity.name) and oldactivity.type == interactions.ActivityType.PLAYING ): - activity_stats["scratch1"] -= 1 + stats["scratch1"] -= 1 # ------------------------------------------------------------------ # # Component interaction handler (settings buttons) # From e0f3e8e69b7df4170b7cc8f823c6290628a4c76f Mon Sep 17 00:00:00 2001 From: Fluffyscratch Date: Sun, 20 Sep 2026 12:57:37 +0200 Subject: [PATCH 4/6] Fix Presence event handling, fix and improve activity stats management --- events/bot_events.py | 86 +++++++++++++++++++------------------------- 1 file changed, 36 insertions(+), 50 deletions(-) diff --git a/events/bot_events.py b/events/bot_events.py index 67e9e28..2ed16ef 100644 --- a/events/bot_events.py +++ b/events/bot_events.py @@ -11,6 +11,27 @@ from config import bot, bot_statuses, button_states, bot_ready from database import add_server +# ------------------------------------------------------------------ # +# Helper functions # +# ------------------------------------------------------------------ # +def classify(activity) -> str | None: + """Map an activity to a stats key, or None if irrelevant.""" + T = interactions.ActivityType + name = activity.name or "" + playing = activity.type == T.PLAYING + streaming = activity.type == T.STREAMING + + if name == "TurboWarp Desktop": + return "turbowarp" if playing else "streaming_turbowarp" if streaming else None + if match(r"Scratch 3\.\d+\.\d+", name): + return "scratch3" if playing else "streaming_scratch3" if streaming else None + if playing and name == "Scratch 2 Offline Editor": + return "scratch2" + if playing and match(r"Scratch 1\.4 of \d{2}-[A-Za-z]{3}-\d{2}", name): + return "scratch1" + return None + +_user_states: dict[int, set[str]] = {} class BotEvents(interactions.Extension): """Extension for core bot lifecycle events.""" @@ -47,56 +68,21 @@ async def on_guild_join(self, event: interactions.events.GuildJoin): if bot_ready: add_server(event.guild.id) - @interactions.listen(interactions.events.MemberUpdate) - async def on_member_update(self, before, after): + @interactions.listen(interactions.events.PresenceUpdate) + async def on_member_update(self, event): """Handle member rich presence update events.""" - if before.activities != after.activities: - # Handle activity changes to update stats - stats = config.activity_stats - for newactivity, oldactivity in zip(after.activities, before.activities): - # Handle new activities - if newactivity.name == "TurboWarp Desktop": - if newactivity.type == interactions.ActivityType.PLAYING: - stats["turbowarp"] += 1 - elif newactivity.type == interactions.ActivityType.STREAMING: - stats["streaming_turbowarp"] += 1 - elif match(r"Scratch 3\.\d+\.\d+", newactivity.name): - if newactivity.type == interactions.ActivityType.PLAYING: - stats["scratch3"] += 1 - elif newactivity.type == interactions.ActivityType.STREAMING: - stats["streaming_scratch3"] += 1 - elif ( - newactivity.name == "Scratch 2 Offline Editor" - and newactivity.type == interactions.ActivityType.PLAYING - ): - stats["scratch2"] += 1 - elif ( - match(r"Scratch 1\.4 of \d{2}-[A-Za-z]{3}-\d{2}", newactivity.name) - and newactivity.type == interactions.ActivityType.PLAYING - ): - stats["scratch1"] += 1 - - # Handle removed activities - if oldactivity.name == "TurboWarp Desktop": - if oldactivity.type == interactions.ActivityType.PLAYING: - stats["turbowarp"] -= 1 - elif oldactivity.type == interactions.ActivityType.STREAMING: - stats["streaming_turbowarp"] -= 1 - elif match(r"Scratch 3\.\d+\.\d+", oldactivity.name): - if oldactivity.type == interactions.ActivityType.PLAYING: - stats["scratch3"] -= 1 - elif oldactivity.type == interactions.ActivityType.STREAMING: - stats["streaming_scratch3"] -= 1 - elif ( - oldactivity.name == "Scratch 2 Offline Editor" - and oldactivity.type == interactions.ActivityType.PLAYING - ): - stats["scratch2"] -= 1 - elif ( - match(r"Scratch 1\.4 of \d{2}-[A-Za-z]{3}-\d{2}", oldactivity.name) - and oldactivity.type == interactions.ActivityType.PLAYING - ): - stats["scratch1"] -= 1 + stats = config.activity_stats + uid = int(event.user.id) + + new = {k for a in event.activities if (k := classify(a))} + old = _user_states.get(uid, set()) + + for key in old - new: + stats[key] -= 1 + for key in new - old: + stats[key] += 1 + + _user_states[uid] = new # ------------------------------------------------------------------ # # Component interaction handler (settings buttons) # @@ -162,7 +148,7 @@ async def on_component(self, event: interactions.events.Component): # ------------------------------------------------------------------ # @interactions.listen() - async def on_command_error(event: CommandError): + async def on_command_error(self, event: CommandError): logging.exception(f"Error in command", exc_info=event.error) try: From ed426dcd8c62619f5bd28dbdeb1fbb2c61445301 Mon Sep 17 00:00:00 2001 From: Fluffyscratch Date: Sun, 20 Sep 2026 15:08:02 +0200 Subject: [PATCH 5/6] Fix streaming stats in /community --- events/bot_events.py | 52 +++++++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/events/bot_events.py b/events/bot_events.py index 2ed16ef..eb80871 100644 --- a/events/bot_events.py +++ b/events/bot_events.py @@ -22,17 +22,39 @@ def classify(activity) -> str | None: streaming = activity.type == T.STREAMING if name == "TurboWarp Desktop": - return "turbowarp" if playing else "streaming_turbowarp" if streaming else None + return "streaming_turbowarp" if streaming else "turbowarp" if playing else None if match(r"Scratch 3\.\d+\.\d+", name): - return "scratch3" if playing else "streaming_scratch3" if streaming else None + return "streaming_scratch3" if streaming else "scratch3" if playing else None if playing and name == "Scratch 2 Offline Editor": return "scratch2" if playing and match(r"Scratch 1\.4 of \d{2}-[A-Za-z]{3}-\d{2}", name): return "scratch1" return None +_presence: dict[int, set[str]] = {} # people currently using Scratch or TurboWarp +_streaming: set[int] = set() # people currently streaming Scratch or TurboWarp +_applied: dict[int, set[str]] = {} # keys currently counted in stats + +def _effective(uid: int) -> set[str]: + keys = set(_presence.get(uid, set())) + if uid in _streaming: + for base in ("turbowarp", "scratch3"): + if base in keys: + keys.add(f"streaming_{base}") + return keys + +def _refresh(uid: int): + stats = config.activity_stats + new, old = _effective(uid), _applied.get(uid, set()) + for k in old - new: + stats[k] -= 1 + for k in new - old: + stats[k] += 1 + _applied[uid] = new + _user_states: dict[int, set[str]] = {} + class BotEvents(interactions.Extension): """Extension for core bot lifecycle events.""" @@ -69,20 +91,20 @@ async def on_guild_join(self, event: interactions.events.GuildJoin): add_server(event.guild.id) @interactions.listen(interactions.events.PresenceUpdate) - async def on_member_update(self, event): - """Handle member rich presence update events.""" - stats = config.activity_stats + async def on_presence_update(self, event: interactions.events.PresenceUpdate): uid = int(event.user.id) - - new = {k for a in event.activities if (k := classify(a))} - old = _user_states.get(uid, set()) - - for key in old - new: - stats[key] -= 1 - for key in new - old: - stats[key] += 1 - - _user_states[uid] = new + _presence[uid] = {k for a in event.activities if (k := classify(a))} + _refresh(uid) + + @interactions.listen(interactions.events.VoiceStateUpdate) + async def on_voice_state_update(self, event: interactions.events.VoiceStateUpdate): + state = event.after or event.before + uid = int(state.member.id) + if event.after and event.after.self_stream: + _streaming.add(uid) + else: + _streaming.discard(uid) # stopped streaming or left voice + _refresh(uid) # ------------------------------------------------------------------ # # Component interaction handler (settings buttons) # From 85ec15d3a25aa6fae3300d48b6b3b8b579c78b5e Mon Sep 17 00:00:00 2001 From: Fluffyscratch Date: Sun, 20 Sep 2026 15:29:54 +0200 Subject: [PATCH 6/6] Make /community more visual --- commands/utility_commands.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/commands/utility_commands.py b/commands/utility_commands.py index aa00982..f6f2c1a 100644 --- a/commands/utility_commands.py +++ b/commands/utility_commands.py @@ -325,11 +325,11 @@ async def community(self, ctx: interactions.SlashContext): stats = config.activity_stats await ctx.send( embed=interactions.Embed( - title=":eyes: What the community is up to:", + title=" What the community is up to:", description=( - f"**{stats['scratch3']}** people are using Scratch 3\n" + f"<:scratch:1330550333751169104> **{stats['scratch3']}** people are using Scratch 3\n" f"- **{stats['streaming_scratch3']}** people are streaming it\n" - f"**{stats['turbowarp']}** people are using TurboWarp Desktop\n" + f"<:Turbowarp:1330552274774396979> **{stats['turbowarp']}** people are using TurboWarp Desktop\n" f"- **{stats['streaming_turbowarp']}** people are streaming it\n" f"**{stats['scratch2']}** people are using Scratch 2\n" f"**{stats['scratch1']}** people are using Scratch 1.4\n"