Files
discord_tools/scripts/profile_stalker.py

1025 lines
47 KiB
Python
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# discord_tools/scripts/profile_stalker.py
import os
import sys
import asyncio
import websockets
import json
import aiohttp
from datetime import datetime
from collections import defaultdict
# Add the parent directory to the Python path
script_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(os.path.dirname(script_dir))
sys.path.insert(0, project_root)
from discord_tools.config.settings import DISCORD_TOKEN, DATA_DIR
from discord_tools.utils.api_utils import make_discord_request
class ProfileStalker:
def __init__(self, user_ids):
self.user_ids = user_ids if isinstance(user_ids, list) else [user_ids]
self.active = True
self.user_data = {} # Stores current state for each user
self.change_log = {
'profile': [], # Username, avatar, banner, bio, etc
'activity': [], # Status, games, Spotify, typing
'social': [] # Friends, servers, roles, nicknames
}
self.message_counts = defaultdict(lambda: defaultdict(int)) # user_id -> channel_id -> count
self.spotify_history = defaultdict(list) # user_id -> [songs]
self.voice_sessions = {} # Track voice session start times
self.voice_history = defaultdict(list) # user_id -> [sessions]
self.active_calls = {} # Track active DM calls
self.start_time = None
self.total_reconnects = 0
# Cache for guild/channel names
self.guild_cache = {}
self.channel_cache = {}
# Initialize user data structure
for user_id in self.user_ids:
self.user_data[user_id] = {
'username': None,
'global_name': None,
'discriminator': None,
'avatar': None,
'avatar_decoration': None,
'banner': None,
'bio': None,
'pronouns': None,
'clan_tag': None,
'connections': [],
'status': None,
'custom_status': None,
'activities': [],
'current_spotify_song': None,
'voice_channel': None,
'voice_guild': None,
'voice_muted': None,
'voice_deafened': None,
'voice_video': None,
'voice_screenshare': None,
'mutual_guilds': [],
'mutual_friends': [],
'guild_nicknames': {},
'guild_roles': {},
'badges': None,
'nitro': None,
'accent_color': None,
'banner_color': None,
'theme_colors': None,
'typing_in': []
}
def get_output_folder(self, user_id, subfolder=None):
"""Get organized output folder path"""
base = os.path.join(DATA_DIR, f"stalker_{user_id}")
if subfolder:
return os.path.join(base, subfolder)
return base
def get_guild_name(self, guild_id):
if guild_id in self.guild_cache:
return self.guild_cache[guild_id]
response = make_discord_request('GET', f'/guilds/{guild_id}')
if response:
name = response.json().get('name', f'Guild-{guild_id}')
self.guild_cache[guild_id] = name
return name
return f'Guild-{guild_id}'
def get_channel_name(self, channel_id):
if channel_id in self.channel_cache:
return self.channel_cache[channel_id]
response = make_discord_request('GET', f'/channels/{channel_id}')
if response:
name = response.json().get('name', f'Channel-{channel_id}')
self.channel_cache[channel_id] = name
return name
return f'Channel-{channel_id}'
def log_change(self, user_id, category, change_type, old_value, new_value, extra_info=None):
"""Log a change with timestamp and category"""
timestamp = datetime.now()
change_entry = {
'timestamp': timestamp.isoformat(),
'user_id': user_id,
'type': change_type,
'old': old_value,
'new': new_value
}
if extra_info:
change_entry['extra'] = extra_info
self.change_log[category].append(change_entry)
# Print to console with category color coding
time_str = timestamp.strftime('%H:%M:%S')
category_prefix = {
'profile': '👤',
'activity': '🎮',
'social': '👥'
}
prefix = category_prefix.get(category, '📝')
print(f"{prefix} [{time_str}] {change_type}: {old_value}{new_value}")
async def download_asset(self, session, url, folder, filename):
try:
async with session.get(url) as resp:
if resp.status == 200:
filepath = os.path.join(folder, filename)
with open(filepath, 'wb') as f:
f.write(await resp.read())
return filepath
except Exception as e:
print(f"Failed to download {filename}: {e}")
return None
async def fetch_user_profile(self, user_id):
response = make_discord_request('GET', f'/users/{user_id}/profile')
if response:
return response.json()
return None
def handle_presence_update(self, data, session):
user_id = data.get('user', {}).get('id')
if user_id not in self.user_ids:
return
user_data = self.user_data[user_id]
# Status change
status = data.get('status')
if status and status != user_data['status']:
self.log_change(user_id, 'activity', 'Status', user_data['status'], status)
user_data['status'] = status
# Activities
activities = data.get('activities', [])
# Custom status
custom_status = None
for activity in activities:
if activity.get('type') == 4:
custom_status = activity.get('state', '')
emoji = activity.get('emoji')
if emoji:
emoji_str = emoji.get('name', '')
custom_status = f"{emoji_str} {custom_status}".strip()
if custom_status != user_data['custom_status']:
self.log_change(user_id, 'activity', 'Custom Status', user_data['custom_status'], custom_status)
user_data['custom_status'] = custom_status
# Other activities
activity_names = []
spotify_song = None
for activity in activities:
if activity.get('type') == 2: # Spotify
song_name = activity.get('details', 'Unknown')
artist = activity.get('state', 'Unknown')
album = activity.get('assets', {}).get('large_text', 'Unknown')
spotify_song = f"{song_name} by {artist}"
activity_names.append(f"Listening to {spotify_song}")
if spotify_song != user_data.get('current_spotify_song'):
self.log_change(user_id, 'activity', 'Spotify',
user_data.get('current_spotify_song'),
spotify_song,
{'song': song_name, 'artist': artist, 'album': album})
user_data['current_spotify_song'] = spotify_song
# Add to Spotify history
self.spotify_history[user_id].append({
'timestamp': datetime.now().isoformat(),
'song': song_name,
'artist': artist,
'album': album
})
elif activity.get('type') in [0, 1]:
activity_type = ['Playing', 'Streaming'][activity['type']]
activity_name = activity.get('name', 'Unknown')
activity_names.append(f"{activity_type} {activity_name}")
if not spotify_song and user_data.get('current_spotify_song'):
self.log_change(user_id, 'activity', 'Spotify', user_data.get('current_spotify_song'), 'Stopped listening')
user_data['current_spotify_song'] = None
current_activities = ', '.join(activity_names) if activity_names else None
old_activities = ', '.join(user_data['activities']) if user_data['activities'] else None
if current_activities != old_activities:
self.log_change(user_id, 'activity', 'Activity', old_activities, current_activities)
user_data['activities'] = activity_names
def handle_call_create(self, data):
channel_id = data.get('channel_id')
ringing = data.get('ringing', [])
for user_id in self.user_ids:
if user_id in ringing:
timestamp = datetime.now().strftime('%H:%M:%S')
print(f"📞 [{timestamp}] Incoming call in DM {channel_id}")
self.active_calls[channel_id] = {
'start_time': datetime.now(),
'participants': ringing
}
def handle_call_update(self, data):
channel_id = data.get('channel_id')
ringing = data.get('ringing', [])
for user_id in self.user_ids:
if user_id in ringing:
timestamp = datetime.now().strftime('%H:%M:%S')
print(f"📞 [{timestamp}] User {user_id} in call {channel_id}")
def handle_call_delete(self, data):
channel_id = data.get('channel_id')
if channel_id in self.active_calls:
call_info = self.active_calls[channel_id]
duration = (datetime.now() - call_info['start_time']).total_seconds()
duration_min = duration / 60
timestamp = datetime.now().strftime('%H:%M:%S')
print(f"📞 [{timestamp}] Call ended in DM {channel_id} (duration: {duration_min:.1f} min)")
del self.active_calls[channel_id]
def handle_guild_member_update(self, data):
member_data = data
user = member_data.get('user', {})
user_id = user.get('id')
if user_id not in self.user_ids:
return
guild_id = member_data.get('guild_id')
user_data = self.user_data[user_id]
# Nickname change
nick = member_data.get('nick')
old_nick = user_data['guild_nicknames'].get(guild_id)
if nick != old_nick:
guild_name = self.get_guild_name(guild_id)
self.log_change(user_id, 'social', 'Nickname',
f"{guild_name}: {old_nick or 'None'}",
f"{guild_name}: {nick or 'None'}",
{'guild_id': guild_id})
if nick:
user_data['guild_nicknames'][guild_id] = nick
elif guild_id in user_data['guild_nicknames']:
del user_data['guild_nicknames'][guild_id]
# Role changes
new_roles = set(member_data.get('roles', []))
old_roles = set(user_data['guild_roles'].get(guild_id, []))
added_roles = new_roles - old_roles
removed_roles = old_roles - new_roles
if added_roles or removed_roles:
guild_name = self.get_guild_name(guild_id)
if added_roles:
self.log_change(user_id, 'social', 'Roles Added',
f"{guild_name}: {len(old_roles)} roles",
f"{guild_name}: {len(new_roles)} roles",
{'guild_id': guild_id, 'added': list(added_roles)})
if removed_roles:
self.log_change(user_id, 'social', 'Roles Removed',
f"{guild_name}: {len(old_roles)} roles",
f"{guild_name}: {len(new_roles)} roles",
{'guild_id': guild_id, 'removed': list(removed_roles)})
user_data['guild_roles'][guild_id] = list(new_roles)
def handle_typing_start(self, data):
user_id = data.get('user_id')
if user_id not in self.user_ids:
return
channel_id = data.get('channel_id')
user_data = self.user_data[user_id]
if channel_id not in user_data['typing_in']:
user_data['typing_in'].append(channel_id)
timestamp = datetime.now().strftime('%H:%M:%S')
print(f"⌨️ [{timestamp}] Started typing in channel {channel_id}")
async def clear_typing():
await asyncio.sleep(10)
if channel_id in user_data['typing_in']:
user_data['typing_in'].remove(channel_id)
asyncio.create_task(clear_typing())
def handle_relationship_add(self, data):
user_id = data.get('id')
if user_id not in self.user_ids:
return
friend = data.get('user', {})
friend_name = f"{friend.get('username', 'Unknown')}#{friend.get('discriminator', '0000')}"
self.log_change(user_id, 'social', 'Friend Added', None, friend_name, {'friend_id': friend.get('id')})
def handle_relationship_remove(self, data):
relationship_id = data.get('id')
timestamp = datetime.now().strftime('%H:%M:%S')
print(f"👥 [{timestamp}] Relationship removed: {relationship_id}")
def handle_voice_state_update(self, data):
user_id = data.get('user_id')
if user_id not in self.user_ids:
return
user_data = self.user_data[user_id]
channel_id = data.get('channel_id')
guild_id = data.get('guild_id')
# Voice state flags
self_mute = data.get('self_mute', False)
self_deaf = data.get('self_deaf', False)
self_video = data.get('self_video', False)
self_stream = data.get('self_stream', False)
# Track mute/deaf changes
if self_mute != user_data['voice_muted']:
self.log_change(user_id, 'activity', 'Voice Muted', user_data['voice_muted'], self_mute)
user_data['voice_muted'] = self_mute
if self_deaf != user_data['voice_deafened']:
self.log_change(user_id, 'activity', 'Voice Deafened', user_data['voice_deafened'], self_deaf)
user_data['voice_deafened'] = self_deaf
if self_video != user_data['voice_video']:
self.log_change(user_id, 'activity', 'Camera', user_data['voice_video'], self_video)
user_data['voice_video'] = self_video
if self_stream != user_data['voice_screenshare']:
self.log_change(user_id, 'activity', 'Screenshare', user_data['voice_screenshare'], self_stream)
user_data['voice_screenshare'] = self_stream
guild_name = self.get_guild_name(guild_id) if guild_id else 'DM'
channel_name = self.get_channel_name(channel_id) if channel_id else None
# Check for channel changes
if channel_id != user_data['voice_channel']:
if channel_id is None:
# Left voice
session_key = f"{user_id}:{user_data.get('voice_channel')}"
if session_key in self.voice_sessions:
start_time = self.voice_sessions[session_key]
duration = (datetime.now() - start_time).total_seconds()
duration_min = duration / 60
old_location = f"{user_data.get('voice_guild', 'Unknown')} > {user_data.get('voice_channel', 'Unknown')}"
self.log_change(user_id, 'activity', 'Voice', old_location, 'Left voice', {
'action': 'left',
'duration_seconds': duration,
'duration_minutes': round(duration_min, 1)
})
# Add to voice history
self.voice_history[user_id].append({
'start': start_time.isoformat(),
'end': datetime.now().isoformat(),
'duration_minutes': round(duration_min, 1),
'location': old_location
})
del self.voice_sessions[session_key]
else:
old_location = f"{user_data.get('voice_guild', 'Unknown')} > {user_data.get('voice_channel', 'Unknown')}"
self.log_change(user_id, 'activity', 'Voice', old_location, 'Left voice', {'action': 'left'})
elif user_data['voice_channel'] is None:
# Joined voice
new_location = f"{guild_name} > {channel_name}"
self.log_change(user_id, 'activity', 'Voice', None, new_location, {'action': 'joined'})
session_key = f"{user_id}:{channel_name}"
self.voice_sessions[session_key] = datetime.now()
else:
# Switched channels
old_session_key = f"{user_id}:{user_data.get('voice_channel')}"
if old_session_key in self.voice_sessions:
start_time = self.voice_sessions[old_session_key]
duration = (datetime.now() - start_time).total_seconds()
duration_min = duration / 60
# Add to voice history
old_location = f"{user_data.get('voice_guild', 'Unknown')} > {user_data.get('voice_channel', 'Unknown')}"
self.voice_history[user_id].append({
'start': start_time.isoformat(),
'end': datetime.now().isoformat(),
'duration_minutes': round(duration_min, 1),
'location': old_location
})
del self.voice_sessions[old_session_key]
else:
duration_min = None
old_location = f"{user_data.get('voice_guild', 'Unknown')} > {user_data.get('voice_channel', 'Unknown')}"
new_location = f"{guild_name} > {channel_name}"
extra_info = {'action': 'moved'}
if duration_min:
extra_info['prev_duration_minutes'] = round(duration_min, 1)
self.log_change(user_id, 'activity', 'Voice', old_location, new_location, extra_info)
# Start tracking new session
new_session_key = f"{user_id}:{channel_name}"
self.voice_sessions[new_session_key] = datetime.now()
user_data['voice_channel'] = channel_name if channel_id else None
user_data['voice_guild'] = guild_name if guild_id else None
def handle_user_update(self, data, session):
user_id = data.get('id')
if user_id not in self.user_ids:
return
user_data = self.user_data[user_id]
# Username change
username = data.get('username')
if username and username != user_data['username']:
self.log_change(user_id, 'profile', 'Username', user_data['username'], username)
user_data['username'] = username
# Display name change
global_name = data.get('global_name')
if global_name != user_data['global_name']:
self.log_change(user_id, 'profile', 'Display Name', user_data['global_name'], global_name)
user_data['global_name'] = global_name
# Discriminator change
discriminator = data.get('discriminator')
if discriminator and discriminator != user_data['discriminator']:
self.log_change(user_id, 'profile', 'Discriminator', user_data['discriminator'], discriminator)
user_data['discriminator'] = discriminator
# Avatar change
avatar = data.get('avatar')
if avatar and avatar != user_data['avatar']:
self.log_change(user_id, 'profile', 'Avatar', user_data['avatar'], avatar)
user_data['avatar'] = avatar
avatar_url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar}.png?size=1024"
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"avatar_{timestamp}.png"
folder = self.get_output_folder(user_id, "profile/avatars")
os.makedirs(folder, exist_ok=True)
asyncio.create_task(self.download_asset(session, avatar_url, folder, filename))
# Banner change
banner = data.get('banner')
if banner and banner != user_data['banner']:
self.log_change(user_id, 'profile', 'Banner', user_data['banner'], banner)
user_data['banner'] = banner
banner_url = f"https://cdn.discordapp.com/banners/{user_id}/{banner}.png?size=1024"
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"banner_{timestamp}.png"
folder = self.get_output_folder(user_id, "profile/banners")
os.makedirs(folder, exist_ok=True)
asyncio.create_task(self.download_asset(session, banner_url, folder, filename))
async def fetch_initial_data(self, session):
print("Fetching initial profile data...")
for user_id in self.user_ids:
profile = await self.fetch_user_profile(user_id)
if profile:
user_info = profile.get('user', {})
user_data = self.user_data[user_id]
# Set initial values
user_data['username'] = user_info.get('username')
user_data['global_name'] = user_info.get('global_name')
user_data['discriminator'] = user_info.get('discriminator')
user_data['avatar'] = user_info.get('avatar')
user_data['banner'] = user_info.get('banner')
user_data['bio'] = user_info.get('bio', '')
user_data['badges'] = user_info.get('public_flags', 0)
user_data['accent_color'] = user_info.get('accent_color')
user_data['avatar_decoration'] = user_info.get('avatar_decoration_data')
user_data['pronouns'] = user_info.get('pronouns', '')
user_data['clan_tag'] = user_info.get('clan', {}).get('tag') if user_info.get('clan') else None
connected_accounts = profile.get('connected_accounts', [])
user_data['connections'] = [
{'type': conn.get('type'), 'name': conn.get('name'), 'visible': conn.get('visibility', 0) == 1}
for conn in connected_accounts
]
premium_type = user_info.get('premium_type', 0)
user_data['nitro'] = ['None', 'Nitro Classic', 'Nitro', 'Nitro Basic'][premium_type] if premium_type < 4 else 'Unknown'
mutual_guilds_data = profile.get('mutual_guilds', [])
user_data['mutual_guilds'] = [g.get('id') for g in mutual_guilds_data]
for guild in mutual_guilds_data:
guild_id = guild.get('id')
guild_name = guild.get('name')
if guild_id and guild_name:
self.guild_cache[guild_id] = guild_name
# Download initial avatar
if user_data['avatar']:
avatar_url = f"https://cdn.discordapp.com/avatars/{user_id}/{user_data['avatar']}.png?size=1024"
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"avatar_initial_{timestamp}.png"
folder = self.get_output_folder(user_id, "profile/avatars")
os.makedirs(folder, exist_ok=True)
await self.download_asset(session, avatar_url, folder, filename)
# Download initial banner
if user_data['banner']:
banner_url = f"https://cdn.discordapp.com/banners/{user_id}/{user_data['banner']}.png?size=1024"
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"banner_initial_{timestamp}.png"
folder = self.get_output_folder(user_id, "profile/banners")
os.makedirs(folder, exist_ok=True)
await self.download_asset(session, banner_url, folder, filename)
print(f" ✓ Loaded: {user_data['global_name'] or user_data['username']} ({user_id})")
print(f" Mutual servers: {len(user_data['mutual_guilds'])}")
print("Initial data loaded\n")
async def periodic_profile_check(self, session):
while self.active:
await asyncio.sleep(300) # Check every 5 minutes
for user_id in self.user_ids:
profile = await self.fetch_user_profile(user_id)
if not profile:
continue
user_data = self.user_data[user_id]
user_info = profile.get('user', {})
# Bio change
bio = user_info.get('bio', '')
if bio != user_data['bio']:
self.log_change(user_id, 'profile', 'Bio', user_data['bio'], bio)
user_data['bio'] = bio
# Pronouns
pronouns = user_info.get('pronouns', '')
if pronouns != user_data['pronouns']:
self.log_change(user_id, 'profile', 'Pronouns', user_data['pronouns'], pronouns)
user_data['pronouns'] = pronouns
# Clan tag
clan_tag = user_info.get('clan', {}).get('tag') if user_info.get('clan') else None
if clan_tag != user_data['clan_tag']:
self.log_change(user_id, 'profile', 'Clan Tag', user_data['clan_tag'], clan_tag)
user_data['clan_tag'] = clan_tag
# Connections
connected_accounts = profile.get('connected_accounts', [])
new_connections = [
{'type': conn.get('type'), 'name': conn.get('name')}
for conn in connected_accounts if conn.get('visibility', 0) == 1
]
old_conn_str = ', '.join([f"{c['type']}:{c['name']}" for c in user_data['connections']])
new_conn_str = ', '.join([f"{c['type']}:{c['name']}" for c in new_connections])
if old_conn_str != new_conn_str:
self.log_change(user_id, 'profile', 'Connections', old_conn_str or 'None', new_conn_str or 'None')
user_data['connections'] = new_connections
# Avatar decoration
avatar_decoration = user_info.get('avatar_decoration_data')
if avatar_decoration != user_data['avatar_decoration']:
self.log_change(user_id, 'profile', 'Avatar Decoration', user_data['avatar_decoration'], avatar_decoration)
user_data['avatar_decoration'] = avatar_decoration
# Badges
flags = user_info.get('public_flags', 0)
if flags != user_data['badges']:
self.log_change(user_id, 'profile', 'Badges', user_data['badges'], flags)
user_data['badges'] = flags
# Accent color
accent_color = user_info.get('accent_color')
if accent_color != user_data['accent_color']:
self.log_change(user_id, 'profile', 'Accent Color', user_data['accent_color'], accent_color)
user_data['accent_color'] = accent_color
# Banner color
banner_color = user_info.get('banner_color')
if banner_color != user_data['banner_color']:
self.log_change(user_id, 'profile', 'Banner Color', user_data['banner_color'], banner_color)
user_data['banner_color'] = banner_color
# Nitro status
premium_type = user_info.get('premium_type', 0)
nitro = ['None', 'Nitro Classic', 'Nitro', 'Nitro Basic'][premium_type] if premium_type < 4 else 'Unknown'
if nitro != user_data['nitro']:
self.log_change(user_id, 'profile', 'Nitro', user_data['nitro'], nitro)
user_data['nitro'] = nitro
# Mutual servers
mutual_guilds_data = profile.get('mutual_guilds', [])
new_mutual_guilds = [g.get('id') for g in mutual_guilds_data]
old_guilds = set(user_data['mutual_guilds'])
new_guilds = set(new_mutual_guilds)
joined = new_guilds - old_guilds
left = old_guilds - new_guilds
for guild_id in joined:
guild_name = self.get_guild_name(guild_id)
self.log_change(user_id, 'social', 'Mutual Server Joined', None, guild_name, {'guild_id': guild_id})
for guild_id in left:
guild_name = self.get_guild_name(guild_id)
self.log_change(user_id, 'social', 'Mutual Server Left', guild_name, None, {'guild_id': guild_id})
user_data['mutual_guilds'] = new_mutual_guilds
async def stalk(self):
"""Main stalking loop with auto-reconnect"""
gateway_url = "wss://gateway.discord.gg/?v=9&encoding=json"
if self.start_time is None:
self.start_time = datetime.now()
while self.active:
try:
await self._connect_and_run(gateway_url)
except (websockets.exceptions.ConnectionClosed,
websockets.exceptions.ConnectionClosedOK,
websockets.exceptions.ConnectionClosedError) as e:
if self.active:
self.total_reconnects += 1
elapsed = (datetime.now() - self.start_time).total_seconds()
print(f"\n⚠️ Connection lost after {elapsed:.0f}s: {e.reason if hasattr(e, 'reason') else str(e)}")
print(f"🔄 Reconnecting... (attempt #{self.total_reconnects})")
await asyncio.sleep(3)
else:
break
except KeyboardInterrupt:
print("\n\n⏹️ Stopping stalker...")
self.active = False
break
except Exception as e:
if self.active:
self.total_reconnects += 1
print(f"\n⚠️ Unexpected error: {e}")
print(f"🔄 Reconnecting... (attempt #{self.total_reconnects})")
await asyncio.sleep(3)
else:
break
async def _connect_and_run(self, gateway_url):
"""Internal method to connect and run gateway loop"""
async with aiohttp.ClientSession() as session:
async with websockets.connect(gateway_url, max_size=16 * 1024 * 1024) as ws:
# Receive Hello
hello = json.loads(await ws.recv())
heartbeat_interval = hello['d']['heartbeat_interval']
# Send Identify
identify = {
"op": 2,
"d": {
"token": DISCORD_TOKEN,
"properties": {
"$os": "windows",
"$browser": "chrome",
"$device": "pc"
},
"compress": False
}
}
await ws.send(json.dumps(identify))
# Heartbeat task
async def heartbeat():
while self.active:
try:
await asyncio.sleep(heartbeat_interval / 1000)
if self.active:
await ws.send(json.dumps({"op": 1, "d": None}))
except Exception:
break
heartbeat_task = asyncio.create_task(heartbeat())
# Periodic profile check
profile_check_task = asyncio.create_task(self.periodic_profile_check(session))
try:
# Wait for READY
ready = False
while not ready:
msg = json.loads(await ws.recv())
if msg.get('t') == 'READY':
ready = True
if self.total_reconnects == 0:
print("✓ Connected to Gateway")
else:
elapsed = (datetime.now() - self.start_time).total_seconds()
print(f"✓ Reconnected successfully (total uptime: {elapsed:.0f}s)")
# Fetch initial data (only on first connect)
if self.total_reconnects == 0:
await self.fetch_initial_data(session)
print(f"👁️ Stalking {len(self.user_ids)} user(s)")
print("=" * 60)
# Monitor events
while self.active:
msg = await ws.recv()
data = json.loads(msg)
event_type = data.get('t')
if event_type == 'PRESENCE_UPDATE':
self.handle_presence_update(data['d'], session)
elif event_type == 'VOICE_STATE_UPDATE':
self.handle_voice_state_update(data['d'])
elif event_type == 'USER_UPDATE':
self.handle_user_update(data['d'], session)
elif event_type == 'TYPING_START':
self.handle_typing_start(data['d'])
elif event_type == 'RELATIONSHIP_ADD':
self.handle_relationship_add(data['d'])
elif event_type == 'RELATIONSHIP_REMOVE':
self.handle_relationship_remove(data['d'])
elif event_type == 'CALL_CREATE':
self.handle_call_create(data['d'])
elif event_type == 'CALL_UPDATE':
self.handle_call_update(data['d'])
elif event_type == 'CALL_DELETE':
self.handle_call_delete(data['d'])
elif event_type == 'GUILD_MEMBER_UPDATE':
self.handle_guild_member_update(data['d'])
elif event_type == 'MESSAGE_CREATE':
message_data = data['d']
author_id = message_data.get('author', {}).get('id')
if author_id in self.user_ids:
channel_id = message_data.get('channel_id')
guild_id = message_data.get('guild_id')
self.message_counts[author_id][channel_id] += 1
total = self.message_counts[author_id][channel_id]
timestamp = datetime.now().strftime('%H:%M:%S')
if guild_id:
print(f"💬 [{timestamp}] Message sent in server channel {channel_id} (#{total} total)")
else:
print(f"💬 [{timestamp}] Message sent in DM {channel_id} (#{total} total)")
finally:
heartbeat_task.cancel()
profile_check_task.cancel()
def save_logs(self):
"""Save all collected data in organized structure"""
if not any(self.change_log.values()):
print("No changes recorded")
return
# Log any active voice sessions
for session_key, start_time in self.voice_sessions.items():
user_id = session_key.split(':')[0]
duration = (datetime.now() - start_time).total_seconds()
duration_min = duration / 60
print(f"\n User {user_id} was still in voice when tracking stopped ({duration_min:.1f} min)")
timestamp_str = datetime.now().strftime('%Y%m%d_%H%M%S')
for user_id in self.user_ids:
base_folder = self.get_output_folder(user_id)
# Create folder structure
folders = {
'profile': self.get_output_folder(user_id, 'profile'),
'activity': self.get_output_folder(user_id, 'activity'),
'social': self.get_output_folder(user_id, 'social'),
'logs': self.get_output_folder(user_id, 'logs')
}
for folder in folders.values():
os.makedirs(folder, exist_ok=True)
# Save categorized change logs
for category in ['profile', 'activity', 'social']:
user_changes = [c for c in self.change_log[category] if c['user_id'] == user_id]
if user_changes:
log_file = os.path.join(folders[category], f"changes_{timestamp_str}.json")
with open(log_file, 'w', encoding='utf-8') as f:
json.dump(user_changes, f, indent=2, ensure_ascii=False)
# Save message activity
if user_id in self.message_counts and self.message_counts[user_id]:
activity_file = os.path.join(folders['activity'], f"messages_{timestamp_str}.txt")
with open(activity_file, 'w', encoding='utf-8') as f:
f.write(f"Message Activity for {user_id}\n")
f.write("=" * 60 + "\n\n")
for channel_id, count in sorted(self.message_counts[user_id].items(), key=lambda x: x[1], reverse=True):
channel_name = self.get_channel_name(channel_id)
f.write(f"{channel_name} ({channel_id}): {count} messages\n")
# Save Spotify history
if user_id in self.spotify_history and self.spotify_history[user_id]:
spotify_file = os.path.join(folders['activity'], f"spotify_{timestamp_str}.txt")
with open(spotify_file, 'w', encoding='utf-8') as f:
f.write(f"Spotify Listening History\n")
f.write("=" * 60 + "\n\n")
for entry in self.spotify_history[user_id]:
time = entry['timestamp']
f.write(f"[{time}]\n")
f.write(f" 🎵 {entry['song']}\n")
f.write(f" 👤 {entry['artist']}\n")
f.write(f" 💿 {entry['album']}\n\n")
# Save voice history
if user_id in self.voice_history and self.voice_history[user_id]:
voice_file = os.path.join(folders['activity'], f"voice_{timestamp_str}.txt")
with open(voice_file, 'w', encoding='utf-8') as f:
f.write(f"Voice Session History\n")
f.write("=" * 60 + "\n\n")
total_minutes = 0
for session in self.voice_history[user_id]:
f.write(f"📍 {session['location']}\n")
f.write(f" Started: {session['start']}\n")
f.write(f" Ended: {session['end']}\n")
f.write(f" Duration: {session['duration_minutes']} minutes\n\n")
total_minutes += session['duration_minutes']
f.write(f"\n{'=' * 60}\n")
f.write(f"Total voice time: {total_minutes:.1f} minutes ({total_minutes/60:.1f} hours)\n")
# Save complete change log
all_changes = []
for category in ['profile', 'activity', 'social']:
all_changes.extend([c for c in self.change_log[category] if c['user_id'] == user_id])
all_changes.sort(key=lambda x: x['timestamp'])
complete_log_file = os.path.join(folders['logs'], f"all_changes_{timestamp_str}.json")
with open(complete_log_file, 'w', encoding='utf-8') as f:
json.dump(all_changes, f, indent=2, ensure_ascii=False)
# Save current state snapshot
state_file = os.path.join(base_folder, "current_state.json")
with open(state_file, 'w', encoding='utf-8') as f:
json.dump(self.user_data[user_id], f, indent=2, ensure_ascii=False)
# Generate summary
summary_file = os.path.join(folders['logs'], f"summary_{timestamp_str}.txt")
with open(summary_file, 'w', encoding='utf-8') as f:
f.write(f"Profile Stalker Summary\n")
f.write("=" * 60 + "\n\n")
f.write(f"User ID: {user_id}\n")
f.write(f"Username: {self.user_data[user_id]['username']}\n")
f.write(f"Display Name: {self.user_data[user_id]['global_name']}\n")
f.write(f"Session Duration: {(datetime.now() - self.start_time).total_seconds() / 3600:.1f} hours\n")
f.write(f"Reconnections: {self.total_reconnects}\n")
f.write(f"Total Changes: {len(all_changes)}\n\n")
# Count changes by category and type
f.write("Changes by Category:\n")
for category in ['profile', 'activity', 'social']:
category_changes = [c for c in self.change_log[category] if c['user_id'] == user_id]
f.write(f" {category.capitalize()}: {len(category_changes)}\n")
f.write("\nChanges by Type:\n")
change_types = defaultdict(int)
for change in all_changes:
change_types[change['type']] += 1
for change_type, count in sorted(change_types.items()):
f.write(f" {change_type}: {count}\n")
# Activity stats
f.write("\n" + "=" * 60 + "\n")
f.write("Activity Statistics:\n\n")
if user_id in self.message_counts:
total_messages = sum(self.message_counts[user_id].values())
f.write(f"💬 Messages Sent: {total_messages}\n")
f.write(f" Channels: {len(self.message_counts[user_id])}\n")
if user_id in self.spotify_history:
f.write(f"🎵 Songs Listened: {len(self.spotify_history[user_id])}\n")
if user_id in self.voice_history:
total_voice_min = sum(s['duration_minutes'] for s in self.voice_history[user_id])
f.write(f"🎤 Voice Sessions: {len(self.voice_history[user_id])}\n")
f.write(f" Total Time: {total_voice_min:.1f} min ({total_voice_min/60:.1f} hrs)\n")
# Current state
f.write("\n" + "=" * 60 + "\n")
f.write("Current State:\n\n")
f.write(f"Status: {self.user_data[user_id]['status']}\n")
f.write(f"Custom Status: {self.user_data[user_id]['custom_status']}\n")
f.write(f"Nitro: {self.user_data[user_id]['nitro']}\n")
f.write(f"Bio: {self.user_data[user_id]['bio'][:100]}...\n" if self.user_data[user_id]['bio'] else "Bio: None\n")
f.write(f"Mutual Servers: {len(self.user_data[user_id]['mutual_guilds'])}\n")
if self.user_data[user_id]['voice_channel']:
f.write(f"Currently in Voice: {self.user_data[user_id]['voice_guild']} > {self.user_data[user_id]['voice_channel']}\n")
print(f"\n📁 Logs saved to: {base_folder}/")
print(f" └── profile/")
print(f" ├── avatars/")
print(f" ├── banners/")
print(f" └── changes_{timestamp_str}.json")
print(f" └── activity/")
print(f" ├── changes_{timestamp_str}.json")
print(f" ├── messages_{timestamp_str}.txt")
print(f" ├── spotify_{timestamp_str}.txt")
print(f" └── voice_{timestamp_str}.txt")
print(f" └── social/")
print(f" └── changes_{timestamp_str}.json")
print(f" └── logs/")
print(f" ├── all_changes_{timestamp_str}.json")
print(f" └── summary_{timestamp_str}.txt")
print(f" └── current_state.json")
def main():
print("Profile Stalker")
print("=" * 60)
user_input = input("User ID(s) to stalk (comma-separated): ").strip()
user_ids = [u.strip() for u in user_input.split(',') if u.strip()]
if not all(u.isdigit() for u in user_ids):
print("Invalid user ID(s)")
return
stalker = ProfileStalker(user_ids)
print(f"\nStarting stalker for {len(user_ids)} user(s)...")
print("Press Ctrl+C to stop and save logs\n")
try:
asyncio.run(stalker.stalk())
except KeyboardInterrupt:
print("\n\n⏹️ Stopping...")
finally:
stalker.save_logs()
if __name__ == "__main__":
main()