11import asyncio
2- from datetime import datetime , timezone
2+ from datetime import datetime
33from typing import Literal
44import discord
55from discord .enums import InteractionType
6- import requests
6+ import aiohttp
77import sys
88
99from .__init__ import __version__
@@ -20,15 +20,14 @@ class ErrorCodes:
2020 INVALID_API_TOKEN = "Invalid API token, please get one at " + ApiEndpoints .BASE_URL .split ("/api" )[0 ] + " and try again."
2121 DATA_NOT_SENT = "Data cannot be sent to the API, I will try again in a minute."
2222 SUSPENDED_BOT = "Your bot has been suspended, please check your mailbox for more information."
23- INSTANCE_NOT_INITIALIZED = "It seem that you didn't initialize your instance. Please check the docs for more informations."
2423 INVALID_EVENTS_COUNT = "invalid events count"
2524
2625class DiscordAnalytics ():
27- def __init__ (self , client : discord .Client , api_key : str , debug : bool = False ):
26+ def __init__ (self , client : discord .Client , api_key : str , debug : bool = False , chunk_guilds_at_startup : bool = True ):
2827 self .client = client
2928 self .api_key = api_key
3029 self .debug = debug
31- self .is_ready = False
30+ self .chunk_guilds = chunk_guilds_at_startup
3231 self .headers = {
3332 "Content-Type" : "application/json" ,
3433 "Authorization" : f"Bot { api_key } "
@@ -57,105 +56,135 @@ def __init__(self, client: discord.Client, api_key: str, debug: bool = False):
5756 "private_message" : 0
5857 }
5958 }
60-
59+
6160 def track_events (self ):
6261 if not self .client .is_ready ():
6362 @self .client .event
6463 async def on_ready ():
65- self .init ()
64+ await self .init ()
6665 else :
67- self .init ()
66+ asyncio .create_task (self .init ())
67+
6868 @self .client .event
6969 async def on_interaction (interaction : discord .Interaction ):
7070 self .track_interactions (interaction )
71+
7172 @self .client .event
7273 async def on_guild_join (guild : discord .Guild ):
7374 self .trackGuilds (guild , "create" )
75+
7476 @self .client .event
7577 async def on_guild_remove (guild : discord .Guild ):
7678 self .trackGuilds (guild , "delete" )
77-
78- def init (self ):
79+
80+ async def init (self ):
7981 if not isinstance (self .client , discord .Client ):
8082 raise ValueError (ErrorCodes .INVALID_CLIENT_TYPE )
8183 if not self .client .is_ready ():
8284 raise ValueError (ErrorCodes .CLIENT_NOT_READY )
83-
84- response = requests .patch (
85- ApiEndpoints .BOT_URL .replace (":id" , str (self .client .user .id )),
86- headers = self .headers ,
87- json = {
88- "username" : self .client .user .name ,
89- "avatar" : self .client .user ._avatar ,
90- "framework" : "discord.py" ,
91- "version" : __version__
92- }
93- )
94-
95- if response .status_code == 401 :
96- raise ValueError (ErrorCodes .INVALID_API_TOKEN )
97- if response .status_code == 423 :
98- raise ValueError (ErrorCodes .SUSPENDED_BOT )
99- if response .status_code != 200 :
100- raise ValueError (ErrorCodes .INVALID_RESPONSE )
101-
85+
86+ # Proceed with initialization, API calls, etc.
87+ async with aiohttp .ClientSession () as session :
88+ async with session .patch (
89+ ApiEndpoints .BOT_URL .replace (":id" , str (self .client .user .id )),
90+ headers = self .headers ,
91+ json = {
92+ "username" : self .client .user .name ,
93+ "avatar" : self .client .user ._avatar ,
94+ "framework" : "discord.py" ,
95+ "version" : __version__
96+ }
97+ ) as response :
98+ if response .status == 401 :
99+ raise ValueError (ErrorCodes .INVALID_API_TOKEN )
100+ elif response .status == 423 :
101+ raise ValueError (ErrorCodes .SUSPENDED_BOT )
102+ elif response .status != 200 :
103+ raise ValueError (ErrorCodes .INVALID_RESPONSE )
104+
102105 if self .debug :
103106 print ("[DISCORDANALYTICS] Instance successfully initialized" )
104- self .is_ready = True
105107
106108 if self .debug :
107109 if "--dev" in sys .argv :
108110 print ("[DISCORDANALYTICS] DevMode is enabled. Stats will be sent every 30s." )
109111 else :
110112 print ("[DISCORDANALYTICS] DevMode is disabled. Stats will be sent every 5 minutes." )
111113
114+ if not self .chunk_guilds :
115+ await self .load_members_for_all_guilds ()
116+
112117 self .client .loop .create_task (self .send_stats ())
113118
119+ async def load_members_for_all_guilds (self ):
120+ """Load members for each guild when chunk_guilds_at_startup is False."""
121+ tasks = [self .load_members_for_guild (guild ) for guild in self .client .guilds ]
122+ await asyncio .gather (* tasks )
123+
124+ async def load_members_for_guild (self , guild : discord .Guild ):
125+ """Load members for a single guild."""
126+ try :
127+ await guild .chunk ()
128+ if self .debug :
129+ print (f"[DISCORDANALYTICS] Chunked members for guild { guild .name } " )
130+ except Exception :
131+ await self .query_members (guild )
132+
133+ async def query_members (self , guild : discord .Guild ):
134+ """Query members by prefix if chunking fails."""
135+ try :
136+ members = await guild .query_members (query = "" , limit = 1000 )
137+ if self .debug :
138+ print (f"[DISCORDANALYTICS] Queried members for guild { guild .name } : { len (members )} members found." )
139+ except Exception as e :
140+ print (f"[DISCORDANALYTICS] Error querying members for guild { guild .name } : { e } " )
141+
114142 async def send_stats (self ):
115143 await self .client .wait_until_ready ()
116144 while not self .client .is_closed ():
117145 if self .debug :
118146 print ("[DISCORDANALYTICS] Sending stats..." )
119-
147+
120148 guild_count = len (self .client .guilds )
121149 user_count = len (self .client .users )
122150
123- response = requests .post (
124- ApiEndpoints .STATS_URL .replace (":id" , str (self .client .user .id )),
125- headers = self .headers ,
126- json = self .stats
127- )
128-
129- if response .status_code == 401 :
130- raise ValueError (ErrorCodes .INVALID_API_TOKEN )
131- if response .status_code == 423 :
132- raise ValueError (ErrorCodes .SUSPENDED_BOT )
133- if response .status_code != 200 :
134- raise ValueError (ErrorCodes .INVALID_RESPONSE )
135- if response .status_code == 200 :
136- if self .debug :
137- print (f"[DISCORDANALYTICS] Stats { self .stats } sent to the API" )
138-
139- self .stats = {
140- "date" : datetime .today ().strftime ("%Y-%m-%d" ),
141- "guilds" : guild_count ,
142- "users" : user_count ,
143- "interactions" : [],
144- "locales" : [],
145- "guildsLocales" : [],
146- "guildMembers" : self .calculate_guild_members_repartition (),
147- "guildsStats" : [],
148- "addedGuilds" : 0 ,
149- "removedGuilds" : 0 ,
150- "users_type" : {
151- "admin" : 0 ,
152- "moderator" : 0 ,
153- "new_member" : 0 ,
154- "other" : 0 ,
155- "private_message" : 0
156- }
157- }
158-
151+ async with aiohttp .ClientSession () as session :
152+ async with session .post (
153+ ApiEndpoints .STATS_URL .replace (":id" , str (self .client .user .id )),
154+ headers = self .headers ,
155+ json = self .stats
156+ ) as response :
157+ if response .status == 401 :
158+ raise ValueError (ErrorCodes .INVALID_API_TOKEN )
159+ elif response .status == 423 :
160+ raise ValueError (ErrorCodes .SUSPENDED_BOT )
161+ elif response .status != 200 :
162+ raise ValueError (ErrorCodes .INVALID_RESPONSE )
163+
164+ if response .status == 200 :
165+ if self .debug :
166+ print (f"[DISCORDANALYTICS] Stats { self .stats } sent to the API" )
167+
168+ self .stats = {
169+ "date" : datetime .today ().strftime ("%Y-%m-%d" ),
170+ "guilds" : guild_count ,
171+ "users" : user_count ,
172+ "interactions" : [],
173+ "locales" : [],
174+ "guildsLocales" : [],
175+ "guildMembers" : self .calculate_guild_members_repartition (),
176+ "guildsStats" : [],
177+ "addedGuilds" : 0 ,
178+ "removedGuilds" : 0 ,
179+ "users_type" : {
180+ "admin" : 0 ,
181+ "moderator" : 0 ,
182+ "new_member" : 0 ,
183+ "other" : 0 ,
184+ "private_message" : 0
185+ }
186+ }
187+
159188 await asyncio .sleep (10 if "--dev" in sys .argv else 300 )
160189
161190 def calculate_guild_members_repartition (self ):
@@ -177,13 +206,14 @@ def calculate_guild_members_repartition(self):
177206 result ["huge" ] += 1
178207
179208 return result
180-
209+
181210 def track_interactions (self , interaction : discord .Interaction ):
182211 if self .debug :
183212 print ("[DISCORDANALYTICS] Track interactions triggered" )
184- if not self .is_ready :
185- raise ValueError (ErrorCodes .INSTANCE_NOT_INITIALIZED )
186-
213+
214+ if not self .client .is_ready ():
215+ raise ValueError (ErrorCodes .CLIENT_NOT_READY )
216+
187217 locale = next ((x for x in self .stats ["locales" ] if x ["locale" ] == interaction .locale .value ), None )
188218 if locale is not None :
189219 locale ["number" ] += 1
@@ -193,8 +223,9 @@ def track_interactions(self, interaction: discord.Interaction):
193223 "number" : 1
194224 })
195225
196- if interaction .type == InteractionType .application_command or interaction .type == InteractionType .autocomplete :
197- interaction_data = next ((x for x in self .stats ["interactions" ] if x ["name" ] == interaction .data ["name" ] and x ["type" ] == interaction .type .value ), None )
226+ if interaction .type in {InteractionType .application_command , InteractionType .autocomplete }:
227+ interaction_data = next ((x for x in self .stats ["interactions" ]
228+ if x ["name" ] == interaction .data ["name" ] and x ["type" ] == interaction .type .value ), None )
198229 if interaction_data is not None :
199230 interaction_data ["number" ] += 1
200231 else :
@@ -203,8 +234,9 @@ def track_interactions(self, interaction: discord.Interaction):
203234 "number" : 1 ,
204235 "type" : interaction .type .value
205236 })
206- elif interaction .type == InteractionType .component or interaction .type == InteractionType .modal_submit :
207- interaction_data = next ((x for x in self .stats ["interactions" ] if x ["name" ] == interaction .data ["custom_id" ] and x ["type" ] == interaction .type .value ), None )
237+ elif interaction .type in {InteractionType .component , InteractionType .modal_submit }:
238+ interaction_data = next ((x for x in self .stats ["interactions" ]
239+ if x ["name" ] == interaction .data ["custom_id" ] and x ["type" ] == interaction .type .value ), None )
208240 if interaction_data is not None :
209241 interaction_data ["number" ] += 1
210242 else :
@@ -219,9 +251,9 @@ def track_interactions(self, interaction: discord.Interaction):
219251 else :
220252 guilds = []
221253 for guild in self .client .guilds :
222- if guild .preferred_locale is not None :
254+ if guild .preferred_locale :
223255 guild_locale = next ((x for x in guilds if x ["locale" ] == guild .preferred_locale .value ), None )
224- if guild_locale is not None :
256+ if guild_locale :
225257 guild_locale ["number" ] += 1
226258 else :
227259 guilds .append ({
@@ -232,7 +264,7 @@ def track_interactions(self, interaction: discord.Interaction):
232264
233265 guild_data = next ((x for x in self .stats ["guildsStats" ] if x ["guildId" ] == str (interaction .guild .id )), None )
234266 guild_icon = interaction .guild .icon .key if interaction .guild .icon else None
235- if guild_data is not None :
267+ if guild_data :
236268 guild_data ["interactions" ] += 1
237269 guild_data ["icon" ] = guild_icon
238270 else :
@@ -243,30 +275,28 @@ def track_interactions(self, interaction: discord.Interaction):
243275 "members" : interaction .guild .member_count ,
244276 "interactions" : 1
245277 })
246-
278+
247279 if interaction .user .guild_permissions .administrator or interaction .user .guild_permissions .manage_guild :
248280 self .stats ["users_type" ]["admin" ] += 1
249- elif (
250- interaction .user .guild_permissions .manage_messages
251- or interaction .user .guild_permissions .kick_members
252- or interaction .user .guild_permissions .ban_members
253- or interaction .user .guild_permissions .mute_members
254- or interaction .user .guild_permissions .deafen_members
255- or interaction .user .guild_permissions .move_members
256- or interaction .user .guild_permissions .moderate_members
257- ):
281+ elif any (perm for perm in [
282+ interaction .user .guild_permissions .manage_messages ,
283+ interaction .user .guild_permissions .kick_members ,
284+ interaction .user .guild_permissions .ban_members ,
285+ interaction .user .guild_permissions .mute_members ,
286+ interaction .user .guild_permissions .deafen_members ,
287+ interaction .user .guild_permissions .move_members ,
288+ interaction .user .guild_permissions .moderate_members ]):
258289 self .stats ["users_type" ]["moderator" ] += 1
259- elif interaction .user .joined_at is not None and (discord .utils .utcnow () - interaction .user .joined_at ).days <= 7 :
290+ elif interaction .user .joined_at and (discord .utils .utcnow () - interaction .user .joined_at ).days <= 7 :
260291 self .stats ["users_type" ]["new_member" ] += 1
261292 else :
262293 self .stats ["users_type" ]["other" ] += 1
263294
264- # type = delete or create
265295 def trackGuilds (self , guild : discord .Guild , type : Literal ["create" , "delete" ]):
266296 if self .debug :
267297 print (f"[DISCORDANALYTICS] trackGuilds({ type } ) triggered" )
268298
269299 if type == "create" :
270300 self .stats ["addedGuilds" ] += 1
271301 elif type == "delete" :
272- self .stats ["removedGuilds" ] += 1
302+ self .stats ["removedGuilds" ] += 1
0 commit comments