1025 lines
47 KiB
Python
Executable File
1025 lines
47 KiB
Python
Executable File
# 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()
|