Initial commit: Discord automation tools
This commit is contained in:
2
.env
Executable file
2
.env
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
DISCORD_TOKEN=NDg0MDU1MDM1MjY5ODczNjY0.G2W9zX.yfXm0CGgRYsm1z3ohKQa0YnTeL42vBr7Pd30ng
|
||||||
|
STEN=MTI5OTA0MjYyMDU0MjE2MDkyNg.GJBnI9.TTmynz89FoEisWWvQ_WFB6SJn2iz_s_4ZthXhY
|
||||||
0
__init__.py
Executable file
0
__init__.py
Executable file
0
config/__init__.py
Executable file
0
config/__init__.py
Executable file
BIN
config/__pycache__/__init__.cpython-313.pyc
Executable file
BIN
config/__pycache__/__init__.cpython-313.pyc
Executable file
Binary file not shown.
BIN
config/__pycache__/__init__.cpython-314.pyc
Executable file
BIN
config/__pycache__/__init__.cpython-314.pyc
Executable file
Binary file not shown.
BIN
config/__pycache__/settings.cpython-313.pyc
Executable file
BIN
config/__pycache__/settings.cpython-313.pyc
Executable file
Binary file not shown.
BIN
config/__pycache__/settings.cpython-314.pyc
Executable file
BIN
config/__pycache__/settings.cpython-314.pyc
Executable file
Binary file not shown.
44
config/settings.py
Executable file
44
config/settings.py
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
# discord_tools/config/settings.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Discord API settings
|
||||||
|
DISCORD_API_BASE_URL = "https://discord.com/api/v9"
|
||||||
|
DISCORD_TOKEN = os.getenv('DISCORD_TOKEN')
|
||||||
|
|
||||||
|
# Rate limiting settings
|
||||||
|
RATE_LIMIT_ATTEMPTS = 5
|
||||||
|
RATE_LIMIT_DELAY = 5 # seconds
|
||||||
|
|
||||||
|
# Logging settings
|
||||||
|
LOG_LEVEL = "INFO"
|
||||||
|
LOG_FILE = "discord_tools.log"
|
||||||
|
|
||||||
|
# File paths
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||||
|
|
||||||
|
# Ensure data directory exists
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# Default values for various tools
|
||||||
|
DEFAULT_STATUS_UPDATE_INTERVAL = 60 # seconds
|
||||||
|
MAX_MESSAGES_PER_REQUEST = 100
|
||||||
|
DEFAULT_AVATAR_SIZE = 128 # pixels
|
||||||
|
|
||||||
|
# Error messages
|
||||||
|
ERROR_MESSAGES = {
|
||||||
|
"token_not_found": "Discord token not found. Please set the DISCORD_TOKEN environment variable.",
|
||||||
|
"rate_limit_exceeded": "Rate limit exceeded. Please try again later.",
|
||||||
|
"api_error": "An error occurred while communicating with the Discord API.",
|
||||||
|
"file_not_found": "The specified file was not found.",
|
||||||
|
"invalid_input": "Invalid input provided. Please check your input and try again.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate required settings
|
||||||
|
if not DISCORD_TOKEN:
|
||||||
|
raise ValueError(ERROR_MESSAGES["token_not_found"])
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 378 KiB |
119
data/stalker_1302643427724234792/changes_20260123_213833.json
Normal file
119
data/stalker_1302643427724234792/changes_20260123_213833.json
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T19:25:36.519988",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Status",
|
||||||
|
"old": null,
|
||||||
|
"new": "online"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T19:27:48.481772",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Status",
|
||||||
|
"old": "online",
|
||||||
|
"new": "offline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T19:27:52.553824",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Connections",
|
||||||
|
"old": "spotify:KC🎶",
|
||||||
|
"new": "None"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T19:27:52.553923",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Banner Color",
|
||||||
|
"old": null,
|
||||||
|
"new": "#8420c9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T19:47:41.580630",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Status",
|
||||||
|
"old": "offline",
|
||||||
|
"new": "online"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T19:49:45.123065",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Status",
|
||||||
|
"old": "online",
|
||||||
|
"new": "offline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T19:52:56.194177",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Status",
|
||||||
|
"old": "offline",
|
||||||
|
"new": "online"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T19:55:00.647950",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Status",
|
||||||
|
"old": "online",
|
||||||
|
"new": "offline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T20:11:43.815805",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Status",
|
||||||
|
"old": "offline",
|
||||||
|
"new": "online"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T20:13:44.312167",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Status",
|
||||||
|
"old": "online",
|
||||||
|
"new": "offline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T21:08:28.130185",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Status",
|
||||||
|
"old": "offline",
|
||||||
|
"new": "online"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T21:08:28.517107",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Spotify",
|
||||||
|
"old": null,
|
||||||
|
"new": "The Devil in I by Slipknot",
|
||||||
|
"extra": {
|
||||||
|
"song": "The Devil in I",
|
||||||
|
"artist": "Slipknot",
|
||||||
|
"album": ".5: The Gray Chapter (Special Edition)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T21:08:28.517145",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Activity",
|
||||||
|
"old": null,
|
||||||
|
"new": "Listening to The Devil in I by Slipknot"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T21:10:28.682004",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Status",
|
||||||
|
"old": "online",
|
||||||
|
"new": "offline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T21:10:28.682038",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Spotify",
|
||||||
|
"old": "The Devil in I by Slipknot",
|
||||||
|
"new": "Stopped listening"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-01-23T21:10:28.682048",
|
||||||
|
"user_id": "1302643427724234792",
|
||||||
|
"type": "Activity",
|
||||||
|
"old": "Listening to The Devil in I by Slipknot",
|
||||||
|
"new": null
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Message Activity for 1302643427724234792
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
Channel 1462022940332916987: 5 messages
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Spotify Listening History
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
[2026-01-23T21:08:28.517107]
|
||||||
|
Song: The Devil in I
|
||||||
|
Artist: Slipknot
|
||||||
|
Album: .5: The Gray Chapter (Special Edition)
|
||||||
|
|
||||||
|
[2026-01-23T21:10:28.682038] Stopped listening
|
||||||
|
|
||||||
38
data/stalker_1302643427724234792/state_20260123_213833.json
Normal file
38
data/stalker_1302643427724234792/state_20260123_213833.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"username": "kc_playys",
|
||||||
|
"global_name": "kc🎶",
|
||||||
|
"discriminator": "0",
|
||||||
|
"avatar": "96fde0a074e8854b40a454edd592ee68",
|
||||||
|
"avatar_decoration": {
|
||||||
|
"asset": "a_57807030ab60f7ac0c4a1998aa091bbf",
|
||||||
|
"sku_id": "1436367668897775757",
|
||||||
|
"expires_at": null
|
||||||
|
},
|
||||||
|
"banner": "af25b4250b21430a75fb84c399c34b4e",
|
||||||
|
"bio": "21🎶",
|
||||||
|
"pronouns": "",
|
||||||
|
"clan_tag": null,
|
||||||
|
"connections": [],
|
||||||
|
"status": "offline",
|
||||||
|
"custom_status": null,
|
||||||
|
"activities": [],
|
||||||
|
"current_spotify_song": null,
|
||||||
|
"voice_channel": null,
|
||||||
|
"voice_guild": null,
|
||||||
|
"voice_muted": null,
|
||||||
|
"voice_deafened": null,
|
||||||
|
"voice_video": null,
|
||||||
|
"voice_screenshare": null,
|
||||||
|
"mutual_guilds": [
|
||||||
|
"1460138748498153627"
|
||||||
|
],
|
||||||
|
"mutual_friends": [],
|
||||||
|
"guild_nicknames": {},
|
||||||
|
"guild_roles": {},
|
||||||
|
"badges": 0,
|
||||||
|
"nitro": "None",
|
||||||
|
"accent_color": 8659145,
|
||||||
|
"banner_color": "#8420c9",
|
||||||
|
"theme_colors": null,
|
||||||
|
"typing_in": []
|
||||||
|
}
|
||||||
21
data/stalker_1302643427724234792/summary_20260123_213833.txt
Normal file
21
data/stalker_1302643427724234792/summary_20260123_213833.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
Profile Stalker Summary
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
User ID: 1302643427724234792
|
||||||
|
Total Changes: 16
|
||||||
|
|
||||||
|
Changes by type:
|
||||||
|
Activity: 2
|
||||||
|
Banner Color: 1
|
||||||
|
Connections: 1
|
||||||
|
Spotify: 2
|
||||||
|
Status: 10
|
||||||
|
|
||||||
|
|
||||||
|
Current State:
|
||||||
|
Username: kc_playys
|
||||||
|
Display Name: kc🎶
|
||||||
|
Status: offline
|
||||||
|
Custom Status: None
|
||||||
|
Voice: None > None
|
||||||
|
Activities: None
|
||||||
56543
data/user_history_360878862403502080/formatted_messages.txt
Normal file
56543
data/user_history_360878862403502080/formatted_messages.txt
Normal file
File diff suppressed because it is too large
Load Diff
922580
data/user_history_360878862403502080/raw_messages.json
Normal file
922580
data/user_history_360878862403502080/raw_messages.json
Normal file
File diff suppressed because it is too large
Load Diff
97
data/user_history_360878862403502080/stats.txt
Normal file
97
data/user_history_360878862403502080/stats.txt
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
Message Statistics for jordanius99#0
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
Total Messages: 18316
|
||||||
|
Servers: 6
|
||||||
|
|
||||||
|
Messages per server:
|
||||||
|
MCCI Trophy Hunting: 29
|
||||||
|
NinjaTrunks Dojo: 6375
|
||||||
|
Jordan Fan Klan (JFK): 141
|
||||||
|
The floggerz: 4377
|
||||||
|
AntiumSMP: 5754
|
||||||
|
🧀Say Cheese🧀: 1640
|
||||||
|
|
||||||
|
Messages per channel:
|
||||||
|
MCCI Trophy Hunting > 1121084204717985814: 29
|
||||||
|
NinjaTrunks Dojo > 1142686248490762315: 4736
|
||||||
|
NinjaTrunks Dojo > 1143087083368493066: 288
|
||||||
|
NinjaTrunks Dojo > 1144888000996716605: 223
|
||||||
|
NinjaTrunks Dojo > 1142686248490762316: 20
|
||||||
|
NinjaTrunks Dojo > 1373136986050658344: 17
|
||||||
|
NinjaTrunks Dojo > 1218305787697561741: 7
|
||||||
|
NinjaTrunks Dojo > 1194226765430796348: 10
|
||||||
|
NinjaTrunks Dojo > 1356655115871846550: 8
|
||||||
|
NinjaTrunks Dojo > 1350232492644237322: 14
|
||||||
|
NinjaTrunks Dojo > 1143097866148130866: 7
|
||||||
|
NinjaTrunks Dojo > 1227868557170315335: 131
|
||||||
|
NinjaTrunks Dojo > 1143095393853374474: 46
|
||||||
|
NinjaTrunks Dojo > 1204843112023982160: 40
|
||||||
|
NinjaTrunks Dojo > 1368055071195005009: 259
|
||||||
|
NinjaTrunks Dojo > 1368057186118275082: 12
|
||||||
|
NinjaTrunks Dojo > 1368055161573998643: 1
|
||||||
|
NinjaTrunks Dojo > 1368056727328788520: 1
|
||||||
|
NinjaTrunks Dojo > 1143096174576943224: 1
|
||||||
|
NinjaTrunks Dojo > 1232052705292320920: 72
|
||||||
|
NinjaTrunks Dojo > 1143097722757451807: 35
|
||||||
|
NinjaTrunks Dojo > 1143096299827241021: 1
|
||||||
|
NinjaTrunks Dojo > 1143087214952202251: 2
|
||||||
|
NinjaTrunks Dojo > 1143095252555661332: 36
|
||||||
|
NinjaTrunks Dojo > 1214983426289963039: 1
|
||||||
|
NinjaTrunks Dojo > 1209036305271623691: 109
|
||||||
|
NinjaTrunks Dojo > 1143095427365863504: 297
|
||||||
|
NinjaTrunks Dojo > 1184319357308907520: 1
|
||||||
|
Jordan Fan Klan (JFK) > 1416528431671148724: 137
|
||||||
|
Jordan Fan Klan (JFK) > 1324519288257773650: 4
|
||||||
|
The floggerz > 1248158801647898684: 128
|
||||||
|
The floggerz > 1242396841887137823: 17
|
||||||
|
The floggerz > 1249463625056391410: 151
|
||||||
|
The floggerz > 1225946660081762307: 1304
|
||||||
|
The floggerz > 1278389964303765525: 143
|
||||||
|
The floggerz > 1235827243192815627: 818
|
||||||
|
The floggerz > 1270837524754989076: 66
|
||||||
|
The floggerz > 1229591143948484628: 915
|
||||||
|
The floggerz > 1373538940497166446: 1
|
||||||
|
The floggerz > 1233946719339806721: 319
|
||||||
|
The floggerz > 1235436589874942015: 52
|
||||||
|
The floggerz > 1228544854385098824: 93
|
||||||
|
The floggerz > 1229985418737487882: 61
|
||||||
|
The floggerz > 1235436642165194752: 102
|
||||||
|
The floggerz > 1313929715626545303: 1
|
||||||
|
The floggerz > 1290040167138197555: 3
|
||||||
|
The floggerz > 1343039025354641429: 7
|
||||||
|
The floggerz > 1234289513392509002: 51
|
||||||
|
The floggerz > 1302448729231462430: 3
|
||||||
|
The floggerz > 1234275560004521994: 2
|
||||||
|
The floggerz > 1331551133985931264: 3
|
||||||
|
The floggerz > 1331893072346353684: 2
|
||||||
|
The floggerz > 1326789372485570571: 1
|
||||||
|
The floggerz > 1225946660081762308: 120
|
||||||
|
The floggerz > 1305676025430151240: 1
|
||||||
|
The floggerz > 1234289577020227614: 11
|
||||||
|
The floggerz > 1266159090594480229: 1
|
||||||
|
The floggerz > 1237575162388283412: 1
|
||||||
|
AntiumSMP > 1234156421105324145: 2512
|
||||||
|
AntiumSMP > 1284335015907426394: 1317
|
||||||
|
AntiumSMP > 1284355292309618740: 211
|
||||||
|
AntiumSMP > 1296643312824094760: 686
|
||||||
|
AntiumSMP > 1284355385804980295: 81
|
||||||
|
AntiumSMP > 1313420009762066442: 548
|
||||||
|
AntiumSMP > 1292279396522004482: 276
|
||||||
|
AntiumSMP > 1284334917760581707: 62
|
||||||
|
AntiumSMP > 1284317186772435005: 1
|
||||||
|
AntiumSMP > 1284355404440272908: 6
|
||||||
|
AntiumSMP > 1410585500057600020: 3
|
||||||
|
AntiumSMP > 1406878301472952383: 1
|
||||||
|
AntiumSMP > 1402500847317024872: 1
|
||||||
|
AntiumSMP > 1402093139719618710: 3
|
||||||
|
AntiumSMP > 1401181361708204195: 1
|
||||||
|
AntiumSMP > 1401060351386058822: 1
|
||||||
|
AntiumSMP > 1400185198238240879: 2
|
||||||
|
AntiumSMP > 1400298503405568020: 1
|
||||||
|
AntiumSMP > 1292279732498464830: 38
|
||||||
|
AntiumSMP > 1234159909050912840: 3
|
||||||
|
🧀Say Cheese🧀 > 1334144698184499344: 963
|
||||||
|
🧀Say Cheese🧀 > 1334151653040853094: 591
|
||||||
|
🧀Say Cheese🧀 > 1334148507459653693: 57
|
||||||
|
🧀Say Cheese🧀 > 1358868355007910016: 29
|
||||||
0
scripts/__init__.py
Executable file
0
scripts/__init__.py
Executable file
240
scripts/backup_server.py
Executable file
240
scripts/backup_server.py
Executable file
@@ -0,0 +1,240 @@
|
|||||||
|
# discord_tools/scripts/server_backup.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
from datetime import datetime
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# 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 DiscordServerBackup:
|
||||||
|
def __init__(self, guild_id):
|
||||||
|
self.guild_id = guild_id
|
||||||
|
self.backup_data = {}
|
||||||
|
self.backup_folder = os.path.join(DATA_DIR, f"server_backup_{guild_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}")
|
||||||
|
self.create_folder_structure()
|
||||||
|
|
||||||
|
def create_folder_structure(self):
|
||||||
|
folders = [
|
||||||
|
self.backup_folder,
|
||||||
|
os.path.join(self.backup_folder, "channels"),
|
||||||
|
os.path.join(self.backup_folder, "images"),
|
||||||
|
os.path.join(self.backup_folder, "images", "emojis"),
|
||||||
|
os.path.join(self.backup_folder, "images", "stickers"),
|
||||||
|
]
|
||||||
|
for folder in folders:
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
|
||||||
|
def download_file(self, url, filepath):
|
||||||
|
response = requests.get(url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
with open(filepath, 'wb') as f:
|
||||||
|
f.write(response.content)
|
||||||
|
print(f"Downloaded {os.path.basename(filepath)}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to download {os.path.basename(filepath)}")
|
||||||
|
|
||||||
|
async def fetch_guild_info(self):
|
||||||
|
endpoint = f'/guilds/{self.guild_id}'
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
if response:
|
||||||
|
guild_info = response.json()
|
||||||
|
self.save_json("guild_info", guild_info)
|
||||||
|
print("Guild information fetched successfully.")
|
||||||
|
|
||||||
|
# Download icon
|
||||||
|
if 'icon' in guild_info:
|
||||||
|
icon_url = f"https://cdn.discordapp.com/icons/{self.guild_id}/{guild_info['icon']}.png"
|
||||||
|
self.download_file(icon_url, os.path.join(self.backup_folder, "images", "guild_icon.png"))
|
||||||
|
|
||||||
|
# Download banner
|
||||||
|
if 'banner' in guild_info:
|
||||||
|
banner_url = f"https://cdn.discordapp.com/banners/{self.guild_id}/{guild_info['banner']}.png"
|
||||||
|
self.download_file(banner_url, os.path.join(self.backup_folder, "images", "guild_banner.png"))
|
||||||
|
else:
|
||||||
|
print("Failed to fetch guild information.")
|
||||||
|
|
||||||
|
async def fetch_channels(self):
|
||||||
|
endpoint = f'/guilds/{self.guild_id}/channels'
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
if response:
|
||||||
|
channels = response.json()
|
||||||
|
self.save_json("channels", channels)
|
||||||
|
|
||||||
|
# Create channel structure
|
||||||
|
channel_structure = self.create_channel_structure(channels)
|
||||||
|
self.save_json("channel_structure", channel_structure)
|
||||||
|
|
||||||
|
# Fetch channel-specific settings
|
||||||
|
await self.fetch_channel_settings(channels)
|
||||||
|
|
||||||
|
print("Channel information and structure fetched successfully.")
|
||||||
|
else:
|
||||||
|
print("Failed to fetch channel information.")
|
||||||
|
|
||||||
|
def create_channel_structure(self, channels):
|
||||||
|
structure = {}
|
||||||
|
categories = {c['id']: c for c in channels if c['type'] == 4}
|
||||||
|
|
||||||
|
for channel in channels:
|
||||||
|
if channel['type'] != 4: # Not a category
|
||||||
|
parent_id = channel.get('parent_id')
|
||||||
|
if parent_id:
|
||||||
|
if parent_id not in structure:
|
||||||
|
structure[parent_id] = []
|
||||||
|
structure[parent_id].append(channel)
|
||||||
|
else:
|
||||||
|
if 'no_category' not in structure:
|
||||||
|
structure['no_category'] = []
|
||||||
|
structure['no_category'].append(channel)
|
||||||
|
|
||||||
|
return structure
|
||||||
|
|
||||||
|
async def fetch_channel_settings(self, channels):
|
||||||
|
for channel in channels:
|
||||||
|
endpoint = f'/channels/{channel["id"]}'
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
if response:
|
||||||
|
channel_data = response.json()
|
||||||
|
self.save_json(f"channels/{channel['id']}_settings", channel_data)
|
||||||
|
else:
|
||||||
|
print(f"Failed to fetch settings for channel {channel['name']}")
|
||||||
|
|
||||||
|
async def fetch_roles(self):
|
||||||
|
endpoint = f'/guilds/{self.guild_id}/roles'
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
if response:
|
||||||
|
roles = response.json()
|
||||||
|
self.save_json("roles", roles)
|
||||||
|
print("Role information fetched successfully.")
|
||||||
|
else:
|
||||||
|
print("Failed to fetch role information.")
|
||||||
|
|
||||||
|
async def fetch_emojis(self):
|
||||||
|
endpoint = f'/guilds/{self.guild_id}/emojis'
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
if response:
|
||||||
|
emojis = response.json()
|
||||||
|
self.save_json("emojis", emojis)
|
||||||
|
print("Emoji information fetched successfully.")
|
||||||
|
|
||||||
|
# Download custom emojis
|
||||||
|
for emoji in emojis:
|
||||||
|
emoji_url = f"https://cdn.discordapp.com/emojis/{emoji['id']}.png"
|
||||||
|
self.download_file(emoji_url, os.path.join(self.backup_folder, "images", "emojis", f"{emoji['name']}.png"))
|
||||||
|
else:
|
||||||
|
print("Failed to fetch emoji information.")
|
||||||
|
|
||||||
|
async def fetch_stickers(self):
|
||||||
|
endpoint = f'/guilds/{self.guild_id}/stickers'
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
if response:
|
||||||
|
stickers = response.json()
|
||||||
|
self.save_json("stickers", stickers)
|
||||||
|
print("Sticker information fetched successfully.")
|
||||||
|
|
||||||
|
# Download custom stickers
|
||||||
|
for sticker in stickers:
|
||||||
|
sticker_url = f"https://cdn.discordapp.com/stickers/{sticker['id']}.png"
|
||||||
|
self.download_file(sticker_url, os.path.join(self.backup_folder, "images", "stickers", f"{sticker['name']}.png"))
|
||||||
|
else:
|
||||||
|
print("Failed to fetch sticker information.")
|
||||||
|
|
||||||
|
async def fetch_invites(self):
|
||||||
|
endpoint = f'/guilds/{self.guild_id}/invites'
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
if response:
|
||||||
|
invites = response.json()
|
||||||
|
self.save_json("invites", invites)
|
||||||
|
print("Server invites fetched successfully.")
|
||||||
|
else:
|
||||||
|
print("Failed to fetch server invites.")
|
||||||
|
|
||||||
|
async def fetch_messages(self, channel_id, limit=None):
|
||||||
|
messages = []
|
||||||
|
last_message_id = None
|
||||||
|
while True:
|
||||||
|
endpoint = f'/channels/{channel_id}/messages?limit=100'
|
||||||
|
if last_message_id:
|
||||||
|
endpoint += f'&before={last_message_id}'
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
if response and response.status_code == 200:
|
||||||
|
new_messages = response.json()
|
||||||
|
if not new_messages:
|
||||||
|
break
|
||||||
|
messages.extend(new_messages)
|
||||||
|
last_message_id = new_messages[-1]['id']
|
||||||
|
if limit and len(messages) >= limit:
|
||||||
|
messages = messages[:limit]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f"Failed to fetch messages for channel {channel_id}")
|
||||||
|
break
|
||||||
|
return messages
|
||||||
|
|
||||||
|
async def backup_messages(self):
|
||||||
|
channels_data = self.load_json("channels")
|
||||||
|
if not channels_data:
|
||||||
|
print("Channel information not available. Skipping message backup.")
|
||||||
|
return
|
||||||
|
|
||||||
|
text_channels = [channel for channel in channels_data if channel['type'] == 0]
|
||||||
|
|
||||||
|
for channel in text_channels:
|
||||||
|
print(f"Backing up messages from channel: {channel['name']}")
|
||||||
|
messages = await self.fetch_messages(channel['id'])
|
||||||
|
self.save_json(f"channels/{channel['id']}_messages", messages)
|
||||||
|
print(f"Backed up {len(messages)} messages from {channel['name']}")
|
||||||
|
|
||||||
|
async def create_backup(self, include_messages=False):
|
||||||
|
tasks = [
|
||||||
|
self.fetch_guild_info(),
|
||||||
|
self.fetch_channels(),
|
||||||
|
self.fetch_roles(),
|
||||||
|
self.fetch_emojis(),
|
||||||
|
self.fetch_stickers(),
|
||||||
|
self.fetch_invites(),
|
||||||
|
]
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
if include_messages:
|
||||||
|
await self.backup_messages()
|
||||||
|
|
||||||
|
def save_json(self, filename, data):
|
||||||
|
filepath = os.path.join(self.backup_folder, f"{filename}.json")
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=4)
|
||||||
|
|
||||||
|
def load_json(self, filename):
|
||||||
|
filepath = os.path.join(self.backup_folder, f"{filename}.json")
|
||||||
|
try:
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"File not found: {filepath}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
guild_id = input("Enter the Discord server ID to backup: ").strip()
|
||||||
|
|
||||||
|
if not guild_id.isdigit():
|
||||||
|
print("Invalid server ID. Please enter a numeric ID.")
|
||||||
|
return
|
||||||
|
|
||||||
|
include_messages = input("Do you want to backup all messages? (y/n): ").strip().lower() == 'y'
|
||||||
|
|
||||||
|
backup = DiscordServerBackup(guild_id)
|
||||||
|
await backup.create_backup(include_messages)
|
||||||
|
print(f"Backup completed. Files saved in {backup.backup_folder}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
99
scripts/chat_fetcher.py
Executable file
99
scripts/chat_fetcher.py
Executable file
@@ -0,0 +1,99 @@
|
|||||||
|
# discord_tools/scripts/chat_fetcher.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 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, MAX_MESSAGES_PER_REQUEST
|
||||||
|
from discord_tools.utils.api_utils import make_discord_request
|
||||||
|
|
||||||
|
def fetch_all_messages(channel_id):
|
||||||
|
"""
|
||||||
|
Fetch all messages from a specific channel.
|
||||||
|
|
||||||
|
:param channel_id: The ID of the channel to fetch messages from
|
||||||
|
:return: List of all fetched messages
|
||||||
|
"""
|
||||||
|
all_messages = []
|
||||||
|
last_message_id = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
endpoint = f'/channels/{channel_id}/messages?limit={MAX_MESSAGES_PER_REQUEST}'
|
||||||
|
if last_message_id:
|
||||||
|
endpoint += f'&before={last_message_id}'
|
||||||
|
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
print(f"Failed to fetch messages. Stopping.")
|
||||||
|
break
|
||||||
|
|
||||||
|
new_messages = response.json()
|
||||||
|
if not new_messages:
|
||||||
|
break
|
||||||
|
|
||||||
|
all_messages.extend(new_messages)
|
||||||
|
print(f"Fetched {len(all_messages)} messages so far...")
|
||||||
|
|
||||||
|
last_message_id = new_messages[-1]['id']
|
||||||
|
time.sleep(1) # To avoid hitting rate limits
|
||||||
|
|
||||||
|
return all_messages
|
||||||
|
|
||||||
|
def format_messages(messages):
|
||||||
|
"""
|
||||||
|
Format the messages for export.
|
||||||
|
|
||||||
|
:param messages: List of message objects
|
||||||
|
:return: List of formatted message dictionaries
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': msg['id'],
|
||||||
|
'content': msg['content'],
|
||||||
|
'author': msg['author']['username'],
|
||||||
|
'timestamp': msg['timestamp'],
|
||||||
|
'attachments': [att['url'] for att in msg.get('attachments', [])],
|
||||||
|
'embeds': msg.get('embeds', [])
|
||||||
|
} for msg in messages
|
||||||
|
]
|
||||||
|
|
||||||
|
def export_to_json(messages, channel_id):
|
||||||
|
"""
|
||||||
|
Export the formatted messages to a JSON file.
|
||||||
|
|
||||||
|
:param messages: List of formatted message dictionaries
|
||||||
|
:param channel_id: The ID of the channel the messages are from
|
||||||
|
"""
|
||||||
|
formatted_messages = format_messages(messages)
|
||||||
|
|
||||||
|
filename = f'channel_{channel_id}_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||||||
|
filepath = os.path.join(DATA_DIR, filename)
|
||||||
|
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(formatted_messages, f, ensure_ascii=False, indent=4)
|
||||||
|
|
||||||
|
print(f"Exported {len(formatted_messages)} messages to {filepath}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
channel_id = input("Enter the channel ID to fetch messages from: ").strip()
|
||||||
|
|
||||||
|
if not channel_id.isdigit():
|
||||||
|
print("Invalid channel ID. Please enter a numeric ID.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Starting to fetch messages...")
|
||||||
|
messages = fetch_all_messages(channel_id)
|
||||||
|
print(f"Finished fetching messages. Total messages: {len(messages)}")
|
||||||
|
|
||||||
|
export_to_json(messages, channel_id)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
267
scripts/discord_message_analyzer.py
Executable file
267
scripts/discord_message_analyzer.py
Executable file
@@ -0,0 +1,267 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import seaborn as sns
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from collections import Counter
|
||||||
|
import nltk
|
||||||
|
from nltk.tokenize import word_tokenize
|
||||||
|
import emoji
|
||||||
|
from sklearn.linear_model import LinearRegression
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore", category=UserWarning, message="Converting to PeriodArray/Index representation will drop timezone information.")
|
||||||
|
|
||||||
|
# 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 DATA_DIR
|
||||||
|
|
||||||
|
# Download required NLTK resources
|
||||||
|
nltk.download('punkt', quiet=True)
|
||||||
|
nltk.download('punkt_tab', quiet=True)
|
||||||
|
|
||||||
|
# Set up plot style
|
||||||
|
plt.style.use('ggplot')
|
||||||
|
plt.rcParams['font.family'] = 'DejaVu Sans'
|
||||||
|
|
||||||
|
class DiscordMessageAnalyzer:
|
||||||
|
def __init__(self, file_name):
|
||||||
|
self.file_path = os.path.join(DATA_DIR, file_name)
|
||||||
|
self.df = self.load_and_process_messages()
|
||||||
|
self.output_dir = os.path.join(DATA_DIR, 'message_analysis')
|
||||||
|
self.combined_dir = os.path.join(self.output_dir, 'combined')
|
||||||
|
self.users = self.df['author'].unique()
|
||||||
|
self.user_dirs = {user: os.path.join(self.output_dir, user) for user in self.users}
|
||||||
|
self.create_output_directories()
|
||||||
|
|
||||||
|
def load_and_process_messages(self):
|
||||||
|
with open(self.file_path, 'r', encoding='utf-8') as file:
|
||||||
|
messages = json.load(file)
|
||||||
|
|
||||||
|
df = pd.DataFrame(messages)
|
||||||
|
df['timestamp'] = pd.to_datetime(df['timestamp'], format='ISO8601')
|
||||||
|
df['timestamp'] = df['timestamp'].dt.tz_convert('Europe/Stockholm')
|
||||||
|
df['date'] = df['timestamp'].dt.date
|
||||||
|
df['hour'] = df['timestamp'].dt.hour
|
||||||
|
df['day_of_week'] = df['timestamp'].dt.dayofweek
|
||||||
|
df['month'] = df['timestamp'].dt.month
|
||||||
|
df['year'] = df['timestamp'].dt.year
|
||||||
|
df['word_count'] = df['content'].astype(str).apply(lambda x: len(x.split()))
|
||||||
|
return df
|
||||||
|
|
||||||
|
def create_output_directories(self):
|
||||||
|
os.makedirs(self.output_dir, exist_ok=True)
|
||||||
|
os.makedirs(self.combined_dir, exist_ok=True)
|
||||||
|
for user_dir in self.user_dirs.values():
|
||||||
|
os.makedirs(user_dir, exist_ok=True)
|
||||||
|
|
||||||
|
def analyze_message_frequency(self, df):
|
||||||
|
daily_messages = df.groupby('date').size().reset_index(name='message_count')
|
||||||
|
peak_day = daily_messages.loc[daily_messages['message_count'].idxmax()]
|
||||||
|
avg_messages = daily_messages['message_count'].mean()
|
||||||
|
weekly_messages = df.groupby(df['timestamp'].dt.to_period('W').astype(str)).size()
|
||||||
|
monthly_messages = df.groupby(df['timestamp'].dt.to_period('M').astype(str)).size()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'daily_messages': daily_messages,
|
||||||
|
'peak_day': peak_day,
|
||||||
|
'avg_messages': avg_messages,
|
||||||
|
'weekly_messages': weekly_messages,
|
||||||
|
'monthly_messages': monthly_messages
|
||||||
|
}
|
||||||
|
|
||||||
|
def visualize_message_frequency(self, data, output_dir):
|
||||||
|
plt.figure(figsize=(12, 6))
|
||||||
|
sns.lineplot(x='date', y='message_count', data=data['daily_messages'])
|
||||||
|
plt.title('Daily Message Frequency')
|
||||||
|
plt.xlabel('Date')
|
||||||
|
plt.ylabel('Number of Messages')
|
||||||
|
plt.xticks(rotation=45)
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(os.path.join(output_dir, 'daily_message_frequency.png'))
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 12))
|
||||||
|
sns.lineplot(data=data['weekly_messages'], ax=ax1)
|
||||||
|
ax1.set_title('Weekly Message Trend')
|
||||||
|
ax1.set_xlabel('Week')
|
||||||
|
ax1.set_ylabel('Number of Messages')
|
||||||
|
ax1.tick_params(axis='x', rotation=45)
|
||||||
|
|
||||||
|
sns.lineplot(data=data['monthly_messages'], ax=ax2)
|
||||||
|
ax2.set_title('Monthly Message Trend')
|
||||||
|
ax2.set_xlabel('Month')
|
||||||
|
ax2.set_ylabel('Number of Messages')
|
||||||
|
ax2.tick_params(axis='x', rotation=45)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(os.path.join(output_dir, 'message_trends.png'))
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
def analyze_timing(self, df):
|
||||||
|
hourly_activity = df.groupby('hour').size()
|
||||||
|
daily_activity = df.groupby('day_of_week').size()
|
||||||
|
return {
|
||||||
|
'hourly_activity': hourly_activity,
|
||||||
|
'daily_activity': daily_activity,
|
||||||
|
}
|
||||||
|
|
||||||
|
def visualize_timing(self, data, output_dir):
|
||||||
|
plt.figure(figsize=(12, 6))
|
||||||
|
sns.barplot(x=data['hourly_activity'].index, y=data['hourly_activity'].values)
|
||||||
|
plt.title('Hourly Message Activity')
|
||||||
|
plt.xlabel('Hour of Day')
|
||||||
|
plt.ylabel('Number of Messages')
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(os.path.join(output_dir, 'hourly_activity.png'))
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
plt.figure(figsize=(12, 6))
|
||||||
|
sns.barplot(x=['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], y=data['daily_activity'].values)
|
||||||
|
plt.title('Daily Message Activity')
|
||||||
|
plt.xlabel('Day of Week')
|
||||||
|
plt.ylabel('Number of Messages')
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(os.path.join(output_dir, 'daily_activity.png'))
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
def analyze_content(self, df):
|
||||||
|
avg_words = df['word_count'].mean()
|
||||||
|
all_words = ' '.join(df['content'].astype(str).str.lower())
|
||||||
|
word_freq = Counter(word_tokenize(all_words))
|
||||||
|
emoji_freq = Counter(char for char in all_words if char in emoji.EMOJI_DATA)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'avg_words': avg_words,
|
||||||
|
'word_freq': word_freq,
|
||||||
|
'emoji_freq': emoji_freq,
|
||||||
|
}
|
||||||
|
|
||||||
|
def visualize_content(self, data, output_dir):
|
||||||
|
plt.figure(figsize=(12, 6))
|
||||||
|
word_freq_df = pd.DataFrame(data['word_freq'].most_common(20), columns=['Word', 'Frequency'])
|
||||||
|
sns.barplot(x='Frequency', y='Word', data=word_freq_df)
|
||||||
|
plt.title('Top 20 Most Common Words')
|
||||||
|
plt.xlabel('Frequency')
|
||||||
|
plt.ylabel('Word')
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(os.path.join(output_dir, 'word_frequency.png'))
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
plt.figure(figsize=(12, 8))
|
||||||
|
emoji_freq_df = pd.DataFrame(data['emoji_freq'].most_common(20), columns=['Emoji', 'Frequency'])
|
||||||
|
bars = plt.barh(emoji_freq_df['Emoji'], emoji_freq_df['Frequency'])
|
||||||
|
plt.title('Top 20 Most Used Emojis')
|
||||||
|
plt.xlabel('Frequency')
|
||||||
|
plt.ylabel('Emoji')
|
||||||
|
for i, bar in enumerate(bars):
|
||||||
|
width = bar.get_width()
|
||||||
|
plt.text(width, bar.get_y() + bar.get_height()/2, f'{width}', ha='left', va='center')
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(os.path.join(output_dir, 'emoji_usage.png'))
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
def analyze_user_comparison(self):
|
||||||
|
user_stats = {user: {
|
||||||
|
'message_count': len(df),
|
||||||
|
'avg_message_length': df['word_count'].mean(),
|
||||||
|
'most_active_hour': df['hour'].value_counts().index[0],
|
||||||
|
'most_used_words': Counter(' '.join(df['content'].astype(str)).split()).most_common(10)
|
||||||
|
} for user, df in self.df.groupby('author')}
|
||||||
|
return user_stats
|
||||||
|
|
||||||
|
def visualize_user_comparison(self, data, output_dir):
|
||||||
|
plt.figure(figsize=(12, 6))
|
||||||
|
sns.barplot(x=[user for user in self.users], y=[data[user]['message_count'] for user in self.users])
|
||||||
|
plt.title('Message Count by User')
|
||||||
|
plt.xlabel('User')
|
||||||
|
plt.ylabel('Number of Messages')
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(os.path.join(output_dir, 'user_message_count.png'))
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
plt.figure(figsize=(12, 6))
|
||||||
|
sns.barplot(x=[user for user in self.users], y=[data[user]['avg_message_length'] for user in self.users])
|
||||||
|
plt.title('Average Message Length by User')
|
||||||
|
plt.xlabel('User')
|
||||||
|
plt.ylabel('Average Word Count')
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(os.path.join(output_dir, 'user_avg_message_length.png'))
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
def analyze_advanced_analytics(self, df):
|
||||||
|
monthly_activity = df.groupby([df['timestamp'].dt.to_period('M').astype(str), 'author']).size().unstack()
|
||||||
|
|
||||||
|
X = np.array(range(len(monthly_activity))).reshape(-1, 1)
|
||||||
|
y = monthly_activity.sum(axis=1).values
|
||||||
|
model = LinearRegression().fit(X, y)
|
||||||
|
future_months = np.array(range(len(monthly_activity), len(monthly_activity) + 6)).reshape(-1, 1)
|
||||||
|
predicted_activity = model.predict(future_months)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'monthly_activity': monthly_activity,
|
||||||
|
'predicted_activity': predicted_activity
|
||||||
|
}
|
||||||
|
|
||||||
|
def visualize_advanced_analytics(self, data, output_dir):
|
||||||
|
plt.figure(figsize=(12, 6))
|
||||||
|
data['monthly_activity'].plot(kind='area', stacked=True)
|
||||||
|
plt.title('Communication Patterns Over Time')
|
||||||
|
plt.xlabel('Month')
|
||||||
|
plt.ylabel('Number of Messages')
|
||||||
|
plt.legend(title='User', bbox_to_anchor=(1.05, 1), loc='upper left')
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(os.path.join(output_dir, 'communication_patterns.png'))
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
plt.figure(figsize=(12, 6))
|
||||||
|
plt.plot(range(len(data['monthly_activity'])), data['monthly_activity'].sum(axis=1), label='Actual')
|
||||||
|
plt.plot(range(len(data['monthly_activity']), len(data['monthly_activity']) + 6), data['predicted_activity'], label='Predicted')
|
||||||
|
plt.title('Message Activity Trend and Prediction')
|
||||||
|
plt.xlabel('Months from Start')
|
||||||
|
plt.ylabel('Number of Messages')
|
||||||
|
plt.legend()
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(os.path.join(output_dir, 'activity_prediction.png'))
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
def run_analysis(self):
|
||||||
|
print("Running combined analysis...")
|
||||||
|
self.run_analysis_for_data(self.df, self.combined_dir)
|
||||||
|
|
||||||
|
for user in self.users:
|
||||||
|
print(f"Running analysis for user: {user}")
|
||||||
|
user_df = self.df[self.df['author'] == user]
|
||||||
|
self.run_analysis_for_data(user_df, self.user_dirs[user])
|
||||||
|
|
||||||
|
def run_analysis_for_data(self, df, output_dir):
|
||||||
|
freq_data = self.analyze_message_frequency(df)
|
||||||
|
self.visualize_message_frequency(freq_data, output_dir)
|
||||||
|
|
||||||
|
timing_data = self.analyze_timing(df)
|
||||||
|
self.visualize_timing(timing_data, output_dir)
|
||||||
|
|
||||||
|
content_data = self.analyze_content(df)
|
||||||
|
self.visualize_content(content_data, output_dir)
|
||||||
|
|
||||||
|
if output_dir == self.combined_dir:
|
||||||
|
user_data = self.analyze_user_comparison()
|
||||||
|
self.visualize_user_comparison(user_data, output_dir)
|
||||||
|
|
||||||
|
advanced_data = self.analyze_advanced_analytics(df)
|
||||||
|
self.visualize_advanced_analytics(advanced_data, output_dir)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
file_name = 'message_data.json'
|
||||||
|
analyzer = DiscordMessageAnalyzer(file_name)
|
||||||
|
analyzer.run_analysis()
|
||||||
|
print(f"Analysis complete. Results saved in '{analyzer.output_dir}'.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
47
scripts/dm_opener.py
Executable file
47
scripts/dm_opener.py
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
# discord_tools/scripts/dm_opener.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 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.utils.api_utils import make_discord_request
|
||||||
|
from discord_tools.config.settings import ERROR_MESSAGES
|
||||||
|
|
||||||
|
def open_dm(recipient_id):
|
||||||
|
"""
|
||||||
|
Opens a Discord DM channel using the recipient's user ID.
|
||||||
|
|
||||||
|
:param recipient_id: The user ID of the recipient
|
||||||
|
:return: The channel ID of the created DM, or None if the request failed
|
||||||
|
"""
|
||||||
|
url = "/users/@me/channels"
|
||||||
|
data = {
|
||||||
|
"recipient_id": recipient_id
|
||||||
|
}
|
||||||
|
|
||||||
|
response = make_discord_request('POST', url, json=data)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
channel_data = response.json()
|
||||||
|
channel_id = channel_data["id"]
|
||||||
|
print(f"DM channel created successfully: {channel_id}")
|
||||||
|
return channel_id
|
||||||
|
else:
|
||||||
|
print(ERROR_MESSAGES["api_error"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def main():
|
||||||
|
recipient_id = input("Enter the recipient's user ID: ")
|
||||||
|
channel_id = open_dm(recipient_id)
|
||||||
|
|
||||||
|
if channel_id:
|
||||||
|
print(f"Opened channel: {channel_id}")
|
||||||
|
else:
|
||||||
|
print("Failed to open DM channel.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
131
scripts/ghost_typer.py
Executable file
131
scripts/ghost_typer.py
Executable file
@@ -0,0 +1,131 @@
|
|||||||
|
# discord_tools/scripts/ghost_typer.py
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 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.utils.api_utils import make_discord_request
|
||||||
|
|
||||||
|
def send_typing_indicator(channel_id):
|
||||||
|
# Send typing indicator to a channel
|
||||||
|
# This makes it look like you're typing
|
||||||
|
endpoint = f'/channels/{channel_id}/typing'
|
||||||
|
response = make_discord_request('POST', endpoint)
|
||||||
|
return response is not None
|
||||||
|
|
||||||
|
def ghost_type_continuous(channel_id, duration=None):
|
||||||
|
# Keep sending typing indicator for a specified duration or indefinitely
|
||||||
|
print(f"Ghost typing in channel: {channel_id}")
|
||||||
|
|
||||||
|
if duration:
|
||||||
|
print(f"Duration: {duration} seconds")
|
||||||
|
else:
|
||||||
|
print("Duration: Infinite (press Ctrl+C to stop)")
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
success = send_typing_indicator(channel_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
count += 1
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print(f"[{count}] Typing indicator sent ({elapsed:.0f}s elapsed)")
|
||||||
|
else:
|
||||||
|
print(f"[!] Failed to send typing indicator")
|
||||||
|
|
||||||
|
# Typing indicator lasts ~10 seconds, send every 8 to be safe
|
||||||
|
time.sleep(8)
|
||||||
|
|
||||||
|
# Check if duration is up
|
||||||
|
if duration and (time.time() - start_time) >= duration:
|
||||||
|
break
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nStopped ghost typing")
|
||||||
|
|
||||||
|
total_time = time.time() - start_time
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"Total indicators sent: {count}")
|
||||||
|
print(f"Total time: {total_time:.0f} seconds")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
def ghost_type_burst(channel_id, count):
|
||||||
|
# Send typing indicator a specific number of times
|
||||||
|
print(f"Sending {count} typing indicators to channel: {channel_id}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
success = send_typing_indicator(channel_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
success_count += 1
|
||||||
|
print(f"[{i+1}/{count}] Sent")
|
||||||
|
else:
|
||||||
|
print(f"[{i+1}/{count}] Failed")
|
||||||
|
|
||||||
|
# Small delay between sends
|
||||||
|
if i < count - 1:
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"Successfully sent: {success_count}/{count}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Ghost Typer")
|
||||||
|
print("=" * 60)
|
||||||
|
print("Send typing indicators without actually typing\n")
|
||||||
|
|
||||||
|
print("1. Continuous ghost typing")
|
||||||
|
print("2. Timed ghost typing")
|
||||||
|
print("3. Burst (send X times)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
choice = input("Choice: ").strip()
|
||||||
|
|
||||||
|
channel_id = input("\nChannel ID: ").strip()
|
||||||
|
|
||||||
|
if not channel_id.isdigit():
|
||||||
|
print("Invalid channel ID")
|
||||||
|
return
|
||||||
|
|
||||||
|
if choice == '1':
|
||||||
|
# Continuous
|
||||||
|
ghost_type_continuous(channel_id)
|
||||||
|
|
||||||
|
elif choice == '2':
|
||||||
|
# Timed
|
||||||
|
duration = input("Duration (seconds): ").strip()
|
||||||
|
|
||||||
|
if not duration.isdigit():
|
||||||
|
print("Invalid duration")
|
||||||
|
return
|
||||||
|
|
||||||
|
ghost_type_continuous(channel_id, int(duration))
|
||||||
|
|
||||||
|
elif choice == '3':
|
||||||
|
# Burst
|
||||||
|
count = input("How many times: ").strip()
|
||||||
|
|
||||||
|
if not count.isdigit():
|
||||||
|
print("Invalid count")
|
||||||
|
return
|
||||||
|
|
||||||
|
ghost_type_burst(channel_id, int(count))
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Invalid choice")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
209
scripts/image_fetcher.py
Executable file
209
scripts/image_fetcher.py
Executable file
@@ -0,0 +1,209 @@
|
|||||||
|
# discord_tools/scripts/image_downloader.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 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.utils.api_utils import make_discord_request
|
||||||
|
from discord_tools.config.settings import ERROR_MESSAGES
|
||||||
|
|
||||||
|
# Image file extensions to look for
|
||||||
|
IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')
|
||||||
|
|
||||||
|
def fetch_messages(channel_id, before=None, limit=100):
|
||||||
|
"""
|
||||||
|
Fetch messages from a Discord channel.
|
||||||
|
|
||||||
|
:param channel_id: The channel ID to fetch messages from
|
||||||
|
:param before: Message ID to fetch messages before (for pagination)
|
||||||
|
:param limit: Number of messages to fetch (max 100)
|
||||||
|
:return: List of messages or None if the request failed
|
||||||
|
"""
|
||||||
|
endpoint = f"/channels/{channel_id}/messages"
|
||||||
|
params = {"limit": limit}
|
||||||
|
|
||||||
|
if before:
|
||||||
|
params["before"] = before
|
||||||
|
|
||||||
|
response = make_discord_request('GET', endpoint, params=params)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
return response.json()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_images_from_messages(messages, user_id=None):
|
||||||
|
"""
|
||||||
|
Extract all image URLs from a list of messages.
|
||||||
|
|
||||||
|
:param messages: List of Discord message objects
|
||||||
|
:param user_id: Optional user ID to filter messages by
|
||||||
|
:return: List of tuples (image_url, filename, message_id, timestamp)
|
||||||
|
"""
|
||||||
|
images = []
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
# Filter by user if specified
|
||||||
|
if user_id and message.get('author', {}).get('id') != user_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
message_id = message.get('id')
|
||||||
|
timestamp = message.get('timestamp', '')
|
||||||
|
|
||||||
|
# Check attachments
|
||||||
|
for attachment in message.get('attachments', []):
|
||||||
|
url = attachment.get('url')
|
||||||
|
filename = attachment.get('filename', 'unknown')
|
||||||
|
|
||||||
|
# Check if it's an image
|
||||||
|
if url and filename.lower().endswith(IMAGE_EXTENSIONS):
|
||||||
|
images.append((url, filename, message_id, timestamp))
|
||||||
|
|
||||||
|
# Check embeds for images
|
||||||
|
for embed in message.get('embeds', []):
|
||||||
|
# Embed image
|
||||||
|
if embed.get('type') == 'image' and embed.get('thumbnail'):
|
||||||
|
url = embed['thumbnail'].get('url')
|
||||||
|
if url:
|
||||||
|
filename = f"embed_{message_id}_{url.split('/')[-1]}"
|
||||||
|
images.append((url, filename, message_id, timestamp))
|
||||||
|
|
||||||
|
# Embed image field
|
||||||
|
if embed.get('image'):
|
||||||
|
url = embed['image'].get('url')
|
||||||
|
if url:
|
||||||
|
filename = f"embed_{message_id}_{url.split('/')[-1]}"
|
||||||
|
images.append((url, filename, message_id, timestamp))
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
def download_image(url, filepath):
|
||||||
|
"""
|
||||||
|
Download an image from a URL to a local file.
|
||||||
|
|
||||||
|
:param url: Image URL
|
||||||
|
:param filepath: Local file path to save the image
|
||||||
|
:return: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = requests.get(url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
with open(filepath, 'wb') as f:
|
||||||
|
f.write(response.content)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to download {url}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def download_all_images(channel_id, output_dir=None, user_id=None):
|
||||||
|
"""
|
||||||
|
Download all images from a Discord channel.
|
||||||
|
|
||||||
|
:param channel_id: The channel ID to download images from
|
||||||
|
:param output_dir: Directory to save images (defaults to project_root/data/images/{channel_id})
|
||||||
|
:param user_id: Optional user ID to filter images by specific user
|
||||||
|
:return: Number of images downloaded
|
||||||
|
"""
|
||||||
|
# Set up output directory
|
||||||
|
if output_dir is None:
|
||||||
|
# Use the project root data folder
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
project_root = os.path.dirname(script_dir)
|
||||||
|
output_dir = os.path.join(project_root, "data", "images", channel_id)
|
||||||
|
|
||||||
|
# Add user ID to path if filtering by user
|
||||||
|
if user_id:
|
||||||
|
output_dir = os.path.join(output_dir, f"user_{user_id}")
|
||||||
|
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
print(f"Fetching messages from channel {channel_id} (filtering by user {user_id})...")
|
||||||
|
else:
|
||||||
|
print(f"Fetching messages from channel {channel_id}...")
|
||||||
|
|
||||||
|
all_images = []
|
||||||
|
before = None
|
||||||
|
total_messages = 0
|
||||||
|
|
||||||
|
# Fetch all messages with pagination
|
||||||
|
while True:
|
||||||
|
messages = fetch_messages(channel_id, before=before, limit=100)
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
if total_messages == 0:
|
||||||
|
print(ERROR_MESSAGES.get("api_error", "Failed to fetch messages"))
|
||||||
|
return 0
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(messages) == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
total_messages += len(messages)
|
||||||
|
print(f"Fetched {total_messages} messages so far...")
|
||||||
|
|
||||||
|
# Extract images from these messages
|
||||||
|
images = extract_images_from_messages(messages, user_id)
|
||||||
|
all_images.extend(images)
|
||||||
|
|
||||||
|
# Set before to the last message ID for pagination
|
||||||
|
before = messages[-1]['id']
|
||||||
|
|
||||||
|
# If we got fewer than 100 messages, we've reached the end
|
||||||
|
if len(messages) < 100:
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"\nFound {len(all_images)} images in {total_messages} messages")
|
||||||
|
|
||||||
|
if len(all_images) == 0:
|
||||||
|
print("No images to download.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Download all images
|
||||||
|
print(f"\nDownloading images to {output_dir}...\n")
|
||||||
|
|
||||||
|
downloaded = 0
|
||||||
|
for i, (url, filename, message_id, timestamp) in enumerate(all_images, 1):
|
||||||
|
# Create a unique filename with timestamp and message ID
|
||||||
|
name, ext = os.path.splitext(filename)
|
||||||
|
safe_filename = f"{i:04d}_{message_id}_{name}{ext}"
|
||||||
|
filepath = os.path.join(output_dir, safe_filename)
|
||||||
|
|
||||||
|
print(f"[{i}/{len(all_images)}] Downloading {filename}...")
|
||||||
|
|
||||||
|
if download_image(url, filepath):
|
||||||
|
downloaded += 1
|
||||||
|
|
||||||
|
print(f"\nSuccessfully downloaded {downloaded}/{len(all_images)} images")
|
||||||
|
print(f"Images saved to: {os.path.abspath(output_dir)}")
|
||||||
|
|
||||||
|
return downloaded
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Discord Image Downloader")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
channel_id = input("Enter the channel ID: ").strip()
|
||||||
|
|
||||||
|
if not channel_id:
|
||||||
|
print("Error: Channel ID cannot be empty")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = input("Enter user ID to filter by (press Enter to download from all users): ").strip()
|
||||||
|
user_id = user_id if user_id else None
|
||||||
|
|
||||||
|
custom_dir = input("Enter output directory (press Enter for default): ").strip()
|
||||||
|
output_dir = custom_dir if custom_dir else None
|
||||||
|
|
||||||
|
print()
|
||||||
|
download_all_images(channel_id, output_dir, user_id)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
157
scripts/message_deletor.py
Executable file
157
scripts/message_deletor.py
Executable file
@@ -0,0 +1,157 @@
|
|||||||
|
# discord_tools/scripts/message_deletor.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 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 MAX_MESSAGES_PER_REQUEST, ERROR_MESSAGES
|
||||||
|
from discord_tools.utils.api_utils import make_discord_request, get_user_id
|
||||||
|
|
||||||
|
def search_messages_in_channel(channel_id, user_id):
|
||||||
|
"""
|
||||||
|
Search for messages from a specific user in a channel.
|
||||||
|
|
||||||
|
:param channel_id: The ID of the channel to search
|
||||||
|
:param user_id: The ID of the user whose messages to search for
|
||||||
|
:return: List of message objects
|
||||||
|
"""
|
||||||
|
messages = []
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
endpoint = f'/channels/{channel_id}/messages/search?author_id={user_id}&offset={offset}'
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
print(f"Failed to search messages in channel {channel_id}")
|
||||||
|
break
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
total_results = data.get('total_results', 0)
|
||||||
|
messages.extend(msg for msg_group in data.get('messages', []) for msg in msg_group)
|
||||||
|
print(f"Found {len(messages)} messages. Total: {len(messages)}/{total_results}")
|
||||||
|
|
||||||
|
if len(messages) >= total_results or not data.get('messages'):
|
||||||
|
break
|
||||||
|
|
||||||
|
offset += MAX_MESSAGES_PER_REQUEST
|
||||||
|
time.sleep(1) # Small delay between searches
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def delete_message(channel_id, message_id):
|
||||||
|
"""
|
||||||
|
Delete a specific message.
|
||||||
|
|
||||||
|
:param channel_id: The ID of the channel containing the message
|
||||||
|
:param message_id: The ID of the message to delete
|
||||||
|
:return: True if deletion was successful, False otherwise
|
||||||
|
"""
|
||||||
|
endpoint = f'/channels/{channel_id}/messages/{message_id}'
|
||||||
|
response = make_discord_request('DELETE', endpoint)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
print(f"Successfully deleted message {message_id} in channel {channel_id}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"Failed to delete message {message_id} in channel {channel_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_messages_search_method(channel_id, user_id):
|
||||||
|
"""
|
||||||
|
Delete all messages from a specific user in a channel using the search method.
|
||||||
|
|
||||||
|
:param channel_id: The ID of the channel to delete messages from
|
||||||
|
:param user_id: The ID of the user whose messages to delete
|
||||||
|
:return: Number of messages deleted
|
||||||
|
"""
|
||||||
|
print(f"Deleting messages using search method in channel {channel_id}")
|
||||||
|
messages = search_messages_in_channel(channel_id, user_id)
|
||||||
|
messages_deleted = 0
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
if delete_message(channel_id, message['id']):
|
||||||
|
messages_deleted += 1
|
||||||
|
|
||||||
|
print(f"Deleted {messages_deleted} messages in channel {channel_id}")
|
||||||
|
return messages_deleted
|
||||||
|
|
||||||
|
def delete_messages_bulk_method(channel_id, user_id):
|
||||||
|
"""
|
||||||
|
Delete all messages from a specific user in a channel using the bulk method.
|
||||||
|
|
||||||
|
:param channel_id: The ID of the channel to delete messages from
|
||||||
|
:param user_id: The ID of the user whose messages to delete
|
||||||
|
:return: Number of messages deleted
|
||||||
|
"""
|
||||||
|
print(f"Deleting messages using bulk method in channel {channel_id}")
|
||||||
|
messages_deleted = 0
|
||||||
|
last_message_id = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
endpoint = f'/channels/{channel_id}/messages?limit=100'
|
||||||
|
if last_message_id:
|
||||||
|
endpoint += f'&before={last_message_id}'
|
||||||
|
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
print(f"Failed to fetch messages in channel {channel_id}")
|
||||||
|
break
|
||||||
|
|
||||||
|
messages = response.json()
|
||||||
|
if not messages:
|
||||||
|
print(f"No more messages to delete in channel {channel_id}")
|
||||||
|
break
|
||||||
|
|
||||||
|
user_messages = [msg for msg in messages if msg['author']['id'] == user_id]
|
||||||
|
for message in user_messages:
|
||||||
|
if delete_message(channel_id, message['id']):
|
||||||
|
messages_deleted += 1
|
||||||
|
|
||||||
|
last_message_id = messages[-1]['id']
|
||||||
|
time.sleep(1) # Small delay between bulk fetches
|
||||||
|
|
||||||
|
print(f"Deleted {messages_deleted} messages in channel {channel_id}")
|
||||||
|
return messages_deleted
|
||||||
|
|
||||||
|
def main():
|
||||||
|
user_id = get_user_id()
|
||||||
|
if not user_id:
|
||||||
|
print(ERROR_MESSAGES["api_error"])
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"User ID: {user_id}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
print("\nChoose a deletion method:")
|
||||||
|
print("1. Search method (more thorough, but slower)")
|
||||||
|
print("2. Bulk method (faster, but might miss some messages in large channels)")
|
||||||
|
print("3. Exit")
|
||||||
|
|
||||||
|
choice = input("Enter your choice (1, 2, or 3): ").strip()
|
||||||
|
|
||||||
|
if choice == '3':
|
||||||
|
print("Exiting the program.")
|
||||||
|
break
|
||||||
|
elif choice not in ['1', '2']:
|
||||||
|
print("Invalid choice. Please enter 1, 2, or 3.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
delete_method = delete_messages_search_method if choice == '1' else delete_messages_bulk_method
|
||||||
|
|
||||||
|
while True:
|
||||||
|
channel_id = input("Enter a channel ID to delete messages from (or press Enter to go back to method selection): ").strip()
|
||||||
|
if not channel_id:
|
||||||
|
break
|
||||||
|
delete_method(channel_id, user_id)
|
||||||
|
|
||||||
|
print("Message deletion process completed.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
204
scripts/pfp_downloader.py
Executable file
204
scripts/pfp_downloader.py
Executable file
@@ -0,0 +1,204 @@
|
|||||||
|
# discord_tools/scripts/pfp_downloader.py
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
import websockets
|
||||||
|
import json
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
# 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, DEFAULT_AVATAR_SIZE, DATA_DIR
|
||||||
|
from discord_tools.utils.api_utils import make_discord_request
|
||||||
|
|
||||||
|
async def download_avatar(session, user, folder):
|
||||||
|
# Download their pfp. Shocking, I know.
|
||||||
|
if user.get('avatar'):
|
||||||
|
avatar_url = f"https://cdn.discordapp.com/avatars/{user['id']}/{user['avatar']}.png?size={DEFAULT_AVATAR_SIZE}"
|
||||||
|
filename = f"{user['username']}_{user['id']}.png"
|
||||||
|
filepath = os.path.join(folder, filename)
|
||||||
|
|
||||||
|
async with session.get(avatar_url) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
with open(filepath, 'wb') as f:
|
||||||
|
f.write(await resp.read())
|
||||||
|
print(f"Downloaded avatar for {user['username']}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to download avatar for {user['username']}")
|
||||||
|
else:
|
||||||
|
print(f"No avatar found for {user['username']}")
|
||||||
|
|
||||||
|
def get_guild_info(guild_id):
|
||||||
|
# Get guild info so we know how many people we're dealing with
|
||||||
|
endpoint = f'/guilds/{guild_id}?with_counts=true'
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
if not response:
|
||||||
|
print(f"Failed to fetch guild info for {guild_id}")
|
||||||
|
return None
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def get_guild_members_via_gateway(guild_id):
|
||||||
|
# Scrape members via gateway because Discord's API is useless for user tokens
|
||||||
|
members = {}
|
||||||
|
gateway_url = "wss://gateway.discord.gg/?v=9&encoding=json"
|
||||||
|
|
||||||
|
# Figure out how many members exist
|
||||||
|
guild_info = get_guild_info(guild_id)
|
||||||
|
if not guild_info:
|
||||||
|
print("Could not get guild info, using default member count estimate")
|
||||||
|
approximate_member_count = 200
|
||||||
|
else:
|
||||||
|
approximate_member_count = guild_info.get('approximate_member_count', 200)
|
||||||
|
print(f"Guild has approximately {approximate_member_count} members")
|
||||||
|
|
||||||
|
async with websockets.connect(gateway_url, max_size=16 * 1024 * 1024) as ws:
|
||||||
|
# Get hello, because apparently we need a handshake
|
||||||
|
hello = json.loads(await ws.recv())
|
||||||
|
heartbeat_interval = hello['d']['heartbeat_interval']
|
||||||
|
|
||||||
|
# Identify ourselves
|
||||||
|
identify = {
|
||||||
|
"op": 2,
|
||||||
|
"d": {
|
||||||
|
"token": DISCORD_TOKEN,
|
||||||
|
"properties": {
|
||||||
|
"$os": "windows",
|
||||||
|
"$browser": "chrome",
|
||||||
|
"$device": "pc"
|
||||||
|
},
|
||||||
|
"compress": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(identify))
|
||||||
|
|
||||||
|
# Keep the connection alive or it'll die on us
|
||||||
|
async def heartbeat():
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(heartbeat_interval / 1000)
|
||||||
|
await ws.send(json.dumps({"op": 1, "d": None}))
|
||||||
|
|
||||||
|
heartbeat_task = asyncio.create_task(heartbeat())
|
||||||
|
|
||||||
|
# Wait for ready event
|
||||||
|
ready = False
|
||||||
|
while not ready:
|
||||||
|
msg = json.loads(await ws.recv())
|
||||||
|
if msg.get('t') == 'READY':
|
||||||
|
ready = True
|
||||||
|
print("Connected to Discord Gateway")
|
||||||
|
|
||||||
|
# Build ranges for member scraping (100 at a time because Discord)
|
||||||
|
ranges = []
|
||||||
|
chunk_size = 100
|
||||||
|
for i in range(0, approximate_member_count + chunk_size, chunk_size):
|
||||||
|
ranges.append([i, min(i + chunk_size - 1, approximate_member_count)])
|
||||||
|
|
||||||
|
print(f"Requesting member list in {len(ranges)} chunks...")
|
||||||
|
|
||||||
|
subscribe = {
|
||||||
|
"op": 14,
|
||||||
|
"d": {
|
||||||
|
"guild_id": guild_id,
|
||||||
|
"typing": False,
|
||||||
|
"activities": False,
|
||||||
|
"threads": False,
|
||||||
|
"members": [],
|
||||||
|
"channels": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Need a channel ID for this to work. Any channel will do.
|
||||||
|
channels_endpoint = f'/guilds/{guild_id}/channels'
|
||||||
|
channels_response = make_discord_request('GET', channels_endpoint)
|
||||||
|
if channels_response:
|
||||||
|
channels = channels_response.json()
|
||||||
|
if channels:
|
||||||
|
first_channel_id = channels[0]['id']
|
||||||
|
subscribe['d']['channels'][first_channel_id] = ranges
|
||||||
|
print(f"Using channel {first_channel_id} for member scraping")
|
||||||
|
|
||||||
|
await ws.send(json.dumps(subscribe))
|
||||||
|
print(f"Sent subscription request")
|
||||||
|
|
||||||
|
# Collect members from the member list updates
|
||||||
|
timeout = 20
|
||||||
|
last_member_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
current_time = asyncio.get_event_loop().time()
|
||||||
|
remaining_timeout = timeout - (current_time - last_member_time)
|
||||||
|
|
||||||
|
if remaining_timeout <= 0:
|
||||||
|
print("Timeout - no new members received")
|
||||||
|
break
|
||||||
|
|
||||||
|
msg = await asyncio.wait_for(ws.recv(), timeout=remaining_timeout)
|
||||||
|
data = json.loads(msg)
|
||||||
|
|
||||||
|
event_type = data.get('t')
|
||||||
|
|
||||||
|
if event_type == 'GUILD_MEMBER_LIST_UPDATE':
|
||||||
|
ops = data['d'].get('ops', [])
|
||||||
|
for op in ops:
|
||||||
|
# SYNC ops contain the bulk of members
|
||||||
|
if op.get('op') == 'SYNC':
|
||||||
|
items = op.get('items', [])
|
||||||
|
for item in items:
|
||||||
|
if 'member' in item:
|
||||||
|
member = item['member']
|
||||||
|
user = member.get('user')
|
||||||
|
if user:
|
||||||
|
members[user['id']] = user
|
||||||
|
last_member_time = asyncio.get_event_loop().time()
|
||||||
|
# INSERT ops are for individual member additions
|
||||||
|
elif op.get('op') == 'INSERT':
|
||||||
|
item = op.get('item', {})
|
||||||
|
if 'member' in item:
|
||||||
|
member = item['member']
|
||||||
|
user = member.get('user')
|
||||||
|
if user:
|
||||||
|
members[user['id']] = user
|
||||||
|
last_member_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
# Progress update so we know it's not frozen
|
||||||
|
if len(members) % 50 == 0 and len(members) > 0:
|
||||||
|
print(f"Collected {len(members)} members so far...")
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(f"Timeout - collected {len(members)} members total")
|
||||||
|
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
|
||||||
|
return members
|
||||||
|
|
||||||
|
async def download_guild_avatars(guild_id):
|
||||||
|
# Main function that does the thing
|
||||||
|
folder = os.path.join(DATA_DIR, f"avatars_{guild_id}")
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
|
||||||
|
print("Fetching guild members via Gateway...")
|
||||||
|
members = await get_guild_members_via_gateway(guild_id)
|
||||||
|
|
||||||
|
print(f"\nTotal members found: {len(members)}")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
tasks = [download_avatar(session, user, folder) for user in members.values()]
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
print(f"All available avatars downloaded for guild {guild_id}!")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
guild_id = input("Enter the guild ID to download avatars from: ").strip()
|
||||||
|
if not guild_id.isdigit():
|
||||||
|
print("Invalid guild ID. Please enter a numeric ID.")
|
||||||
|
return
|
||||||
|
|
||||||
|
asyncio.run(download_guild_avatars(guild_id))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1024
scripts/profile_stalker.py
Executable file
1024
scripts/profile_stalker.py
Executable file
File diff suppressed because it is too large
Load Diff
57
scripts/status_updater.py
Executable file
57
scripts/status_updater.py
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
# discord_tools/scripts/status_updater.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
# 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 DEFAULT_STATUS_UPDATE_INTERVAL
|
||||||
|
from discord_tools.utils.api_utils import make_discord_request
|
||||||
|
|
||||||
|
def update_status(start_datetime):
|
||||||
|
"""
|
||||||
|
Update the Discord status with the time elapsed since start_datetime.
|
||||||
|
|
||||||
|
:param start_datetime: The datetime to calculate the elapsed time from
|
||||||
|
:return: True if the status was updated successfully, False otherwise
|
||||||
|
"""
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
time_diff = now - start_datetime
|
||||||
|
|
||||||
|
days = time_diff.days
|
||||||
|
hours, remainder = divmod(time_diff.seconds, 3600)
|
||||||
|
minutes, _ = divmod(remainder, 60)
|
||||||
|
|
||||||
|
status_text = f"{days} days, {hours} hours, {minutes} minutes"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"custom_status": {
|
||||||
|
"text": status_text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = make_discord_request('PATCH', '/users/@me/settings', json=payload)
|
||||||
|
return response is not None
|
||||||
|
|
||||||
|
def main():
|
||||||
|
start_date = datetime.datetime(2024, 2, 24, 3, 43)
|
||||||
|
update_interval = DEFAULT_STATUS_UPDATE_INTERVAL
|
||||||
|
|
||||||
|
print(f"Starting status updater. Press Ctrl+C to exit.")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if update_status(start_date):
|
||||||
|
print(f"Status updated successfully at {datetime.datetime.now()}")
|
||||||
|
else:
|
||||||
|
print("Failed to update status")
|
||||||
|
time.sleep(update_interval)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStatus updater stopped.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
209
scripts/token_validator.py
Executable file
209
scripts/token_validator.py
Executable file
@@ -0,0 +1,209 @@
|
|||||||
|
# discord_tools/scripts/token_validator.py
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 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_API_BASE_URL
|
||||||
|
|
||||||
|
def validate_token(token):
|
||||||
|
# Check if a Discord token is valid
|
||||||
|
url = f"{DISCORD_API_BASE_URL}/users/@me"
|
||||||
|
headers = {
|
||||||
|
"Authorization": token,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
user_data = response.json()
|
||||||
|
return {
|
||||||
|
'valid': True,
|
||||||
|
'user_id': user_data['id'],
|
||||||
|
'username': user_data['username'],
|
||||||
|
'discriminator': user_data.get('discriminator', '0'),
|
||||||
|
'email': user_data.get('email', 'N/A'),
|
||||||
|
'verified': user_data.get('verified', False),
|
||||||
|
'mfa_enabled': user_data.get('mfa_enabled', False),
|
||||||
|
'flags': user_data.get('flags', 0),
|
||||||
|
'premium_type': user_data.get('premium_type', 0),
|
||||||
|
'phone': user_data.get('phone', 'N/A')
|
||||||
|
}
|
||||||
|
elif response.status_code == 401:
|
||||||
|
return {'valid': False, 'error': 'Invalid token'}
|
||||||
|
elif response.status_code == 403:
|
||||||
|
return {'valid': False, 'error': 'Token is valid but locked/disabled'}
|
||||||
|
else:
|
||||||
|
return {'valid': False, 'error': f'HTTP {response.status_code}'}
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
return {'valid': False, 'error': f'Request failed: {str(e)}'}
|
||||||
|
|
||||||
|
def get_token_type(token):
|
||||||
|
# Determine what kind of token it is
|
||||||
|
if token.startswith('mfa.'):
|
||||||
|
return 'MFA Token'
|
||||||
|
elif token.startswith('Bot '):
|
||||||
|
return 'Bot Token'
|
||||||
|
elif '.' in token:
|
||||||
|
# User tokens typically have format: base64.base64.base64
|
||||||
|
parts = token.split('.')
|
||||||
|
if len(parts) == 3:
|
||||||
|
return 'User Token'
|
||||||
|
return 'Unknown'
|
||||||
|
|
||||||
|
def print_token_info(result, token):
|
||||||
|
# Print info about a token
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
|
||||||
|
if result['valid']:
|
||||||
|
print("✓ TOKEN IS VALID")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Type: {get_token_type(token)}")
|
||||||
|
print(f"User ID: {result['user_id']}")
|
||||||
|
print(f"Username: {result['username']}#{result['discriminator']}")
|
||||||
|
print(f"Email: {result['email']}")
|
||||||
|
print(f"Verified: {result['verified']}")
|
||||||
|
print(f"MFA Enabled: {result['mfa_enabled']}")
|
||||||
|
print(f"Phone: {result['phone']}")
|
||||||
|
|
||||||
|
# Decode premium type
|
||||||
|
premium_types = {
|
||||||
|
0: 'None',
|
||||||
|
1: 'Nitro Classic',
|
||||||
|
2: 'Nitro',
|
||||||
|
3: 'Nitro Basic'
|
||||||
|
}
|
||||||
|
print(f"Premium: {premium_types.get(result['premium_type'], 'Unknown')}")
|
||||||
|
else:
|
||||||
|
print("✗ TOKEN IS INVALID")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Type: {get_token_type(token)}")
|
||||||
|
print(f"Error: {result['error']}")
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
def validate_tokens_from_file(filepath):
|
||||||
|
# Validate multiple tokens from a file
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
print(f"File not found: {filepath}")
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(filepath, 'r') as f:
|
||||||
|
tokens = [line.strip() for line in f if line.strip()]
|
||||||
|
|
||||||
|
print(f"Validating {len(tokens)} tokens from {filepath}...")
|
||||||
|
|
||||||
|
valid_count = 0
|
||||||
|
invalid_count = 0
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for i, token in enumerate(tokens, 1):
|
||||||
|
print(f"\n[{i}/{len(tokens)}] Checking token...")
|
||||||
|
result = validate_token(token)
|
||||||
|
|
||||||
|
if result['valid']:
|
||||||
|
valid_count += 1
|
||||||
|
status = "VALID"
|
||||||
|
else:
|
||||||
|
invalid_count += 1
|
||||||
|
status = "INVALID"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'token': token,
|
||||||
|
'status': status,
|
||||||
|
'result': result
|
||||||
|
})
|
||||||
|
|
||||||
|
print_token_info(result, token)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("SUMMARY")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Total Tokens: {len(tokens)}")
|
||||||
|
print(f"Valid: {valid_count}")
|
||||||
|
print(f"Invalid: {invalid_count}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Save results
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
output_file = f"token_validation_{timestamp}.txt"
|
||||||
|
|
||||||
|
with open(output_file, 'w') as f:
|
||||||
|
f.write(f"Token Validation Results\n")
|
||||||
|
f.write(f"Validated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||||
|
f.write(f"Total: {len(tokens)} | Valid: {valid_count} | Invalid: {invalid_count}\n")
|
||||||
|
f.write("=" * 80 + "\n\n")
|
||||||
|
|
||||||
|
for i, item in enumerate(results, 1):
|
||||||
|
f.write(f"[{i}] {item['status']}\n")
|
||||||
|
f.write(f"Token: {item['token'][:20]}...{item['token'][-10:]}\n")
|
||||||
|
|
||||||
|
if item['result']['valid']:
|
||||||
|
f.write(f"User: {item['result']['username']}#{item['result']['discriminator']}\n")
|
||||||
|
f.write(f"ID: {item['result']['user_id']}\n")
|
||||||
|
else:
|
||||||
|
f.write(f"Error: {item['result']['error']}\n")
|
||||||
|
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
print(f"\nResults saved to: {output_file}")
|
||||||
|
|
||||||
|
def validate_env_token():
|
||||||
|
# Check the token from the .env file
|
||||||
|
try:
|
||||||
|
from discord_tools.config.settings import DISCORD_TOKEN
|
||||||
|
|
||||||
|
if not DISCORD_TOKEN:
|
||||||
|
print("No token found in .env file")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\nValidating token from .env file...")
|
||||||
|
result = validate_token(DISCORD_TOKEN)
|
||||||
|
print_token_info(result, DISCORD_TOKEN)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load token from .env: {e}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Discord Token Validator")
|
||||||
|
print("=" * 60)
|
||||||
|
print("1. Validate single token")
|
||||||
|
print("2. Validate tokens from file")
|
||||||
|
print("3. Validate token from .env")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
choice = input("Choice: ").strip()
|
||||||
|
|
||||||
|
if choice == '1':
|
||||||
|
token = input("\nEnter Discord token: ").strip()
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
print("No token provided")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\nValidating token...")
|
||||||
|
result = validate_token(token)
|
||||||
|
print_token_info(result, token)
|
||||||
|
|
||||||
|
elif choice == '2':
|
||||||
|
filepath = input("\nEnter path to tokens file (one token per line): ").strip()
|
||||||
|
validate_tokens_from_file(filepath)
|
||||||
|
|
||||||
|
elif choice == '3':
|
||||||
|
validate_env_token()
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Invalid choice")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
242
scripts/user_history_scraper.py
Executable file
242
scripts/user_history_scraper.py
Executable file
@@ -0,0 +1,242 @@
|
|||||||
|
# discord_tools/scripts/user_history_scraper.py
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 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 DATA_DIR
|
||||||
|
from discord_tools.utils.api_utils import make_discord_request
|
||||||
|
|
||||||
|
def get_mutual_guilds():
|
||||||
|
# Get all servers you're in
|
||||||
|
response = make_discord_request('GET', '/users/@me/guilds')
|
||||||
|
if not response:
|
||||||
|
print("Failed to fetch guilds")
|
||||||
|
return []
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def get_guild_channels(guild_id):
|
||||||
|
# Get channels in a server
|
||||||
|
response = make_discord_request('GET', f'/guilds/{guild_id}/channels')
|
||||||
|
if not response:
|
||||||
|
return []
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def search_user_messages_in_guild(guild_id, user_id):
|
||||||
|
# Use Discord's search API to find messages from a specific user
|
||||||
|
# Way faster than manually paginating through every channel
|
||||||
|
all_messages = []
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
endpoint = f'/guilds/{guild_id}/messages/search?author_id={user_id}&offset={offset}'
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
break
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
messages = data.get('messages', [])
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Discord returns messages in a weird nested format
|
||||||
|
# Each item is a list containing related messages
|
||||||
|
for message_group in messages:
|
||||||
|
if isinstance(message_group, list):
|
||||||
|
for msg in message_group:
|
||||||
|
if msg['author']['id'] == user_id:
|
||||||
|
all_messages.append(msg)
|
||||||
|
else:
|
||||||
|
if message_group['author']['id'] == user_id:
|
||||||
|
all_messages.append(message_group)
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
offset += 25
|
||||||
|
total_results = data.get('total_results', 0)
|
||||||
|
|
||||||
|
print(f" Found {len(all_messages)} messages so far...")
|
||||||
|
|
||||||
|
# If we've gotten all results, stop
|
||||||
|
if offset >= total_results:
|
||||||
|
break
|
||||||
|
|
||||||
|
return all_messages
|
||||||
|
|
||||||
|
def get_user_info_from_guild(guild_id, user_id):
|
||||||
|
# Try to get user info from guild member endpoint
|
||||||
|
endpoint = f'/guilds/{guild_id}/members/{user_id}'
|
||||||
|
response = make_discord_request('GET', endpoint)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
member_data = response.json()
|
||||||
|
return member_data.get('user')
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def format_message_for_export(msg):
|
||||||
|
# Convert message to a readable format
|
||||||
|
timestamp = datetime.fromisoformat(msg['timestamp'].replace('Z', '+00:00'))
|
||||||
|
|
||||||
|
formatted = {
|
||||||
|
'id': msg['id'],
|
||||||
|
'timestamp': timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'content': msg['content'],
|
||||||
|
'attachments': [att['url'] for att in msg.get('attachments', [])],
|
||||||
|
'embeds': len(msg.get('embeds', [])),
|
||||||
|
'mentions': [f"{u['username']}#{u['discriminator']}" for u in msg.get('mentions', [])],
|
||||||
|
'edited': msg.get('edited_timestamp') is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("User History Scraper")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
user_id = input("Enter the user ID to scrape: ").strip()
|
||||||
|
|
||||||
|
if not user_id.isdigit():
|
||||||
|
print("Invalid user ID")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\nScraping messages from user ID: {user_id}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Get all mutual guilds
|
||||||
|
guilds = get_mutual_guilds()
|
||||||
|
print(f"Found {len(guilds)} servers")
|
||||||
|
|
||||||
|
all_scraped_messages = {}
|
||||||
|
total_messages = 0
|
||||||
|
username = "Unknown"
|
||||||
|
|
||||||
|
for guild in guilds:
|
||||||
|
guild_id = guild['id']
|
||||||
|
guild_name = guild['name']
|
||||||
|
|
||||||
|
print(f"\nSearching {guild_name}...")
|
||||||
|
|
||||||
|
# Try to get user info from this guild
|
||||||
|
if username == "Unknown":
|
||||||
|
user_info = get_user_info_from_guild(guild_id, user_id)
|
||||||
|
if user_info:
|
||||||
|
username = f"{user_info['username']}#{user_info.get('discriminator', '0')}"
|
||||||
|
print(f" Found user: {username}")
|
||||||
|
|
||||||
|
# Use search API to find all messages from this user in this guild
|
||||||
|
messages = search_user_messages_in_guild(guild_id, user_id)
|
||||||
|
|
||||||
|
if messages:
|
||||||
|
# Organize by channel
|
||||||
|
channels_dict = {}
|
||||||
|
for msg in messages:
|
||||||
|
channel_id = msg['channel_id']
|
||||||
|
if channel_id not in channels_dict:
|
||||||
|
channels_dict[channel_id] = []
|
||||||
|
channels_dict[channel_id].append(msg)
|
||||||
|
|
||||||
|
all_scraped_messages[guild_name] = channels_dict
|
||||||
|
total_messages += len(messages)
|
||||||
|
print(f" Total in {guild_name}: {len(messages)} messages across {len(channels_dict)} channels")
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"Scraping complete. Total messages found: {total_messages}")
|
||||||
|
|
||||||
|
if total_messages == 0:
|
||||||
|
print("No messages found for this user.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# If we still don't have username, try to get it from first message
|
||||||
|
if username == "Unknown":
|
||||||
|
for guild_messages in all_scraped_messages.values():
|
||||||
|
for messages in guild_messages.values():
|
||||||
|
if messages:
|
||||||
|
first_msg = messages[0]
|
||||||
|
author = first_msg['author']
|
||||||
|
username = f"{author['username']}#{author.get('discriminator', '0')}"
|
||||||
|
break
|
||||||
|
if username != "Unknown":
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"User identified as: {username}")
|
||||||
|
|
||||||
|
# Save to file
|
||||||
|
output_folder = os.path.join(DATA_DIR, f"user_history_{user_id}")
|
||||||
|
os.makedirs(output_folder, exist_ok=True)
|
||||||
|
|
||||||
|
# Save raw JSON
|
||||||
|
raw_file = os.path.join(output_folder, "raw_messages.json")
|
||||||
|
with open(raw_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(all_scraped_messages, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Save formatted text file
|
||||||
|
formatted_file = os.path.join(output_folder, "formatted_messages.txt")
|
||||||
|
with open(formatted_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(f"Message History for {username}\n")
|
||||||
|
f.write(f"Scraped on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||||
|
f.write(f"Total Messages: {total_messages}\n")
|
||||||
|
f.write("=" * 80 + "\n\n")
|
||||||
|
|
||||||
|
for guild_name, channels in all_scraped_messages.items():
|
||||||
|
f.write(f"\n{'=' * 80}\n")
|
||||||
|
f.write(f"SERVER: {guild_name}\n")
|
||||||
|
f.write(f"{'=' * 80}\n\n")
|
||||||
|
|
||||||
|
for channel_id, messages in channels.items():
|
||||||
|
# Try to get channel name from first message
|
||||||
|
channel_name = f"channel-{channel_id}"
|
||||||
|
if messages:
|
||||||
|
# Channel info might be in message metadata
|
||||||
|
channel_name = messages[0].get('channel_id', channel_id)
|
||||||
|
|
||||||
|
f.write(f"\n--- Channel {channel_name} ({len(messages)} messages) ---\n\n")
|
||||||
|
|
||||||
|
# Sort messages by timestamp
|
||||||
|
sorted_messages = sorted(messages, key=lambda x: x['timestamp'])
|
||||||
|
|
||||||
|
for msg in sorted_messages:
|
||||||
|
formatted = format_message_for_export(msg)
|
||||||
|
f.write(f"[{formatted['timestamp']}]\n")
|
||||||
|
f.write(f"{formatted['content']}\n")
|
||||||
|
|
||||||
|
if formatted['attachments']:
|
||||||
|
f.write(f"Attachments: {', '.join(formatted['attachments'])}\n")
|
||||||
|
|
||||||
|
if formatted['edited']:
|
||||||
|
f.write("(edited)\n")
|
||||||
|
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
# Generate summary stats
|
||||||
|
stats_file = os.path.join(output_folder, "stats.txt")
|
||||||
|
with open(stats_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(f"Message Statistics for {username}\n")
|
||||||
|
f.write("=" * 50 + "\n\n")
|
||||||
|
f.write(f"Total Messages: {total_messages}\n")
|
||||||
|
f.write(f"Servers: {len(all_scraped_messages)}\n\n")
|
||||||
|
|
||||||
|
f.write("Messages per server:\n")
|
||||||
|
for guild_name, channels in all_scraped_messages.items():
|
||||||
|
guild_total = sum(len(msgs) for msgs in channels.values())
|
||||||
|
f.write(f" {guild_name}: {guild_total}\n")
|
||||||
|
|
||||||
|
f.write("\nMessages per channel:\n")
|
||||||
|
for guild_name, channels in all_scraped_messages.items():
|
||||||
|
for channel_id, messages in channels.items():
|
||||||
|
f.write(f" {guild_name} > {channel_id}: {len(messages)}\n")
|
||||||
|
|
||||||
|
print(f"\nResults saved to: {output_folder}")
|
||||||
|
print(f" - raw_messages.json (complete data)")
|
||||||
|
print(f" - formatted_messages.txt (readable format)")
|
||||||
|
print(f" - stats.txt (summary)")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
218
scripts/video_fetcher.py
Executable file
218
scripts/video_fetcher.py
Executable file
@@ -0,0 +1,218 @@
|
|||||||
|
# discord_tools/scripts/video_downloader.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 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.utils.api_utils import make_discord_request
|
||||||
|
from discord_tools.config.settings import ERROR_MESSAGES
|
||||||
|
|
||||||
|
# Video file extensions to look for
|
||||||
|
VIDEO_EXTENSIONS = ('.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.wmv', '.m4v', '.mpeg', '.mpg', '.3gp', '.ogv')
|
||||||
|
|
||||||
|
def fetch_messages(channel_id, before=None, limit=100):
|
||||||
|
"""
|
||||||
|
Fetch messages from a Discord channel.
|
||||||
|
|
||||||
|
:param channel_id: The channel ID to fetch messages from
|
||||||
|
:param before: Message ID to fetch messages before (for pagination)
|
||||||
|
:param limit: Number of messages to fetch (max 100)
|
||||||
|
:return: List of messages or None if the request failed
|
||||||
|
"""
|
||||||
|
endpoint = f"/channels/{channel_id}/messages"
|
||||||
|
params = {"limit": limit}
|
||||||
|
|
||||||
|
if before:
|
||||||
|
params["before"] = before
|
||||||
|
|
||||||
|
response = make_discord_request('GET', endpoint, params=params)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
return response.json()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_videos_from_messages(messages, user_id=None):
|
||||||
|
"""
|
||||||
|
Extract all video URLs from a list of messages.
|
||||||
|
|
||||||
|
:param messages: List of Discord message objects
|
||||||
|
:param user_id: Optional user ID to filter messages by
|
||||||
|
:return: List of tuples (video_url, filename, message_id, timestamp)
|
||||||
|
"""
|
||||||
|
videos = []
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
# Filter by user if specified
|
||||||
|
if user_id and message.get('author', {}).get('id') != user_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
message_id = message.get('id')
|
||||||
|
timestamp = message.get('timestamp', '')
|
||||||
|
|
||||||
|
# Check attachments
|
||||||
|
for attachment in message.get('attachments', []):
|
||||||
|
url = attachment.get('url')
|
||||||
|
filename = attachment.get('filename', 'unknown')
|
||||||
|
|
||||||
|
# Check if it's a video
|
||||||
|
if url and filename.lower().endswith(VIDEO_EXTENSIONS):
|
||||||
|
videos.append((url, filename, message_id, timestamp))
|
||||||
|
|
||||||
|
# Check embeds for videos
|
||||||
|
for embed in message.get('embeds', []):
|
||||||
|
# Embed video
|
||||||
|
if embed.get('type') == 'video' and embed.get('video'):
|
||||||
|
url = embed['video'].get('url')
|
||||||
|
if url:
|
||||||
|
filename = f"embed_{message_id}_{url.split('/')[-1]}"
|
||||||
|
videos.append((url, filename, message_id, timestamp))
|
||||||
|
|
||||||
|
return videos
|
||||||
|
|
||||||
|
def download_video(url, filepath):
|
||||||
|
"""
|
||||||
|
Download a video from a URL to a local file.
|
||||||
|
|
||||||
|
:param url: Video URL
|
||||||
|
:param filepath: Local file path to save the video
|
||||||
|
:return: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
print(f" Downloading from {url}...")
|
||||||
|
response = requests.get(url, timeout=60, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Download in chunks for large files
|
||||||
|
total_size = int(response.headers.get('content-length', 0))
|
||||||
|
downloaded = 0
|
||||||
|
|
||||||
|
with open(filepath, 'wb') as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if total_size > 0:
|
||||||
|
percent = (downloaded / total_size) * 100
|
||||||
|
print(f" Progress: {percent:.1f}%", end='\r')
|
||||||
|
|
||||||
|
if total_size > 0:
|
||||||
|
print(f" Progress: 100.0%")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Failed to download {url}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def download_all_videos(channel_id, output_dir=None, user_id=None):
|
||||||
|
"""
|
||||||
|
Download all videos from a Discord channel.
|
||||||
|
|
||||||
|
:param channel_id: The channel ID to download videos from
|
||||||
|
:param output_dir: Directory to save videos (defaults to project_root/data/videos/{channel_id})
|
||||||
|
:param user_id: Optional user ID to filter videos by specific user
|
||||||
|
:return: Number of videos downloaded
|
||||||
|
"""
|
||||||
|
# Set up output directory
|
||||||
|
if output_dir is None:
|
||||||
|
# Use the project root data folder
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
project_root = os.path.dirname(script_dir)
|
||||||
|
output_dir = os.path.join(project_root, "data", "videos", channel_id)
|
||||||
|
|
||||||
|
# Add user ID to path if filtering by user
|
||||||
|
if user_id:
|
||||||
|
output_dir = os.path.join(output_dir, f"user_{user_id}")
|
||||||
|
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
print(f"Fetching messages from channel {channel_id} (filtering by user {user_id})...")
|
||||||
|
else:
|
||||||
|
print(f"Fetching messages from channel {channel_id}...")
|
||||||
|
|
||||||
|
all_videos = []
|
||||||
|
before = None
|
||||||
|
total_messages = 0
|
||||||
|
|
||||||
|
# Fetch all messages with pagination
|
||||||
|
while True:
|
||||||
|
messages = fetch_messages(channel_id, before=before, limit=100)
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
if total_messages == 0:
|
||||||
|
print(ERROR_MESSAGES.get("api_error", "Failed to fetch messages"))
|
||||||
|
return 0
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(messages) == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
total_messages += len(messages)
|
||||||
|
print(f"Fetched {total_messages} messages so far...")
|
||||||
|
|
||||||
|
# Extract videos from these messages
|
||||||
|
videos = extract_videos_from_messages(messages, user_id)
|
||||||
|
all_videos.extend(videos)
|
||||||
|
|
||||||
|
# Set before to the last message ID for pagination
|
||||||
|
before = messages[-1]['id']
|
||||||
|
|
||||||
|
# If we got fewer than 100 messages, we've reached the end
|
||||||
|
if len(messages) < 100:
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"\nFound {len(all_videos)} videos in {total_messages} messages")
|
||||||
|
|
||||||
|
if len(all_videos) == 0:
|
||||||
|
print("No videos to download.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Download all videos
|
||||||
|
print(f"\nDownloading videos to {output_dir}...\n")
|
||||||
|
|
||||||
|
downloaded = 0
|
||||||
|
for i, (url, filename, message_id, timestamp) in enumerate(all_videos, 1):
|
||||||
|
# Create a unique filename with timestamp and message ID
|
||||||
|
name, ext = os.path.splitext(filename)
|
||||||
|
safe_filename = f"{i:04d}_{message_id}_{name}{ext}"
|
||||||
|
filepath = os.path.join(output_dir, safe_filename)
|
||||||
|
|
||||||
|
print(f"[{i}/{len(all_videos)}] Downloading {filename}...")
|
||||||
|
|
||||||
|
if download_video(url, filepath):
|
||||||
|
downloaded += 1
|
||||||
|
file_size = os.path.getsize(filepath) / (1024 * 1024) # MB
|
||||||
|
print(f" Saved: {safe_filename} ({file_size:.2f} MB)\n")
|
||||||
|
|
||||||
|
print(f"\nSuccessfully downloaded {downloaded}/{len(all_videos)} videos")
|
||||||
|
print(f"Videos saved to: {os.path.abspath(output_dir)}")
|
||||||
|
|
||||||
|
return downloaded
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Discord Video Downloader")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
channel_id = input("Enter the channel ID: ").strip()
|
||||||
|
|
||||||
|
if not channel_id:
|
||||||
|
print("Error: Channel ID cannot be empty")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = input("Enter user ID to filter by (press Enter to download from all users): ").strip()
|
||||||
|
user_id = user_id if user_id else None
|
||||||
|
|
||||||
|
custom_dir = input("Enter output directory (press Enter for default): ").strip()
|
||||||
|
output_dir = custom_dir if custom_dir else None
|
||||||
|
|
||||||
|
print()
|
||||||
|
download_all_videos(channel_id, output_dir, user_id)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
473
scripts/voice_activity_tracker.py
Executable file
473
scripts/voice_activity_tracker.py
Executable file
@@ -0,0 +1,473 @@
|
|||||||
|
# discord_tools/scripts/voice_activity_tracker.py
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
import websockets
|
||||||
|
import json
|
||||||
|
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 VoiceActivityTracker:
|
||||||
|
def __init__(self, guild_ids=None, user_ids=None, exclude_guilds=None):
|
||||||
|
# Support multiple guilds or specific users
|
||||||
|
self.guild_ids = guild_ids if isinstance(guild_ids, list) else ([guild_ids] if guild_ids else [])
|
||||||
|
self.user_ids = user_ids if isinstance(user_ids, list) else ([user_ids] if user_ids else [])
|
||||||
|
self.exclude_guilds = exclude_guilds if isinstance(exclude_guilds, list) else ([exclude_guilds] if exclude_guilds else [])
|
||||||
|
|
||||||
|
self.voice_states = {} # user_id -> {channel_id, joined_at, user_info, guild_id}
|
||||||
|
self.session_log = [] # List of all voice sessions
|
||||||
|
self.active = True
|
||||||
|
self.guild_names = {} # Cache guild names
|
||||||
|
self.channel_names = {} # Cache channel names
|
||||||
|
|
||||||
|
def get_guild_info(self, guild_id):
|
||||||
|
# Get guild name and cache it
|
||||||
|
if guild_id in self.guild_names:
|
||||||
|
return self.guild_names[guild_id]
|
||||||
|
|
||||||
|
response = make_discord_request('GET', f'/guilds/{guild_id}')
|
||||||
|
if response:
|
||||||
|
guild_data = response.json()
|
||||||
|
self.guild_names[guild_id] = guild_data['name']
|
||||||
|
return guild_data['name']
|
||||||
|
|
||||||
|
self.guild_names[guild_id] = f'Guild-{guild_id}'
|
||||||
|
return f'Guild-{guild_id}'
|
||||||
|
|
||||||
|
def get_voice_channels(self, guild_id):
|
||||||
|
# Get all voice channels in a guild
|
||||||
|
response = make_discord_request('GET', f'/guilds/{guild_id}/channels')
|
||||||
|
if not response:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
channels = response.json()
|
||||||
|
voice_channels = {}
|
||||||
|
|
||||||
|
for channel in channels:
|
||||||
|
# Type 2 = voice channel, Type 13 = stage channel
|
||||||
|
if channel['type'] in [2, 13]:
|
||||||
|
voice_channels[channel['id']] = channel['name']
|
||||||
|
self.channel_names[channel['id']] = channel['name']
|
||||||
|
|
||||||
|
return voice_channels
|
||||||
|
|
||||||
|
def should_track_event(self, guild_id, user_id):
|
||||||
|
# Check if we should track this event based on filters
|
||||||
|
|
||||||
|
# Check excluded guilds
|
||||||
|
if guild_id in self.exclude_guilds:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If specific guilds set, only track those
|
||||||
|
if self.guild_ids and guild_id not in self.guild_ids:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If specific users set, only track those
|
||||||
|
if self.user_ids and user_id not in self.user_ids:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def handle_voice_state_update(self, data):
|
||||||
|
# Process voice state changes
|
||||||
|
user_id = data['user_id']
|
||||||
|
channel_id = data.get('channel_id') # None if user left voice
|
||||||
|
guild_id = data.get('guild_id')
|
||||||
|
|
||||||
|
# Check if we should track this event
|
||||||
|
if not self.should_track_event(guild_id, user_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
timestamp = datetime.now()
|
||||||
|
|
||||||
|
# User info
|
||||||
|
member = data.get('member', {})
|
||||||
|
user = member.get('user', data.get('user', {}))
|
||||||
|
username = f"{user.get('username', 'Unknown')}#{user.get('discriminator', '0000')}"
|
||||||
|
|
||||||
|
# Get guild and channel names
|
||||||
|
guild_name = self.get_guild_info(guild_id)
|
||||||
|
|
||||||
|
# Build unique key for user+guild combo
|
||||||
|
state_key = f"{user_id}:{guild_id}"
|
||||||
|
|
||||||
|
# Check if user was already in a voice channel in this guild
|
||||||
|
if state_key in self.voice_states:
|
||||||
|
old_state = self.voice_states[state_key]
|
||||||
|
|
||||||
|
# User left voice completely
|
||||||
|
if channel_id is None:
|
||||||
|
duration = (timestamp - old_state['joined_at']).total_seconds()
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'user_id': user_id,
|
||||||
|
'username': username,
|
||||||
|
'guild_id': guild_id,
|
||||||
|
'guild_name': guild_name,
|
||||||
|
'channel_id': old_state['channel_id'],
|
||||||
|
'channel_name': old_state.get('channel_name', 'Unknown'),
|
||||||
|
'joined_at': old_state['joined_at'].isoformat(),
|
||||||
|
'left_at': timestamp.isoformat(),
|
||||||
|
'duration_seconds': duration
|
||||||
|
}
|
||||||
|
|
||||||
|
self.session_log.append(session)
|
||||||
|
del self.voice_states[state_key]
|
||||||
|
|
||||||
|
print(f"[-] {username} left {old_state.get('channel_name', 'Unknown')} in {guild_name} (stayed {duration:.0f}s)")
|
||||||
|
|
||||||
|
# User switched channels
|
||||||
|
elif channel_id != old_state['channel_id']:
|
||||||
|
duration = (timestamp - old_state['joined_at']).total_seconds()
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'user_id': user_id,
|
||||||
|
'username': username,
|
||||||
|
'guild_id': guild_id,
|
||||||
|
'guild_name': guild_name,
|
||||||
|
'channel_id': old_state['channel_id'],
|
||||||
|
'channel_name': old_state.get('channel_name', 'Unknown'),
|
||||||
|
'joined_at': old_state['joined_at'].isoformat(),
|
||||||
|
'left_at': timestamp.isoformat(),
|
||||||
|
'duration_seconds': duration
|
||||||
|
}
|
||||||
|
|
||||||
|
self.session_log.append(session)
|
||||||
|
|
||||||
|
# Get new channel name
|
||||||
|
if channel_id not in self.channel_names:
|
||||||
|
self.get_voice_channels(guild_id)
|
||||||
|
|
||||||
|
new_channel_name = self.channel_names.get(channel_id, f'Channel-{channel_id}')
|
||||||
|
|
||||||
|
self.voice_states[state_key] = {
|
||||||
|
'channel_id': channel_id,
|
||||||
|
'channel_name': new_channel_name,
|
||||||
|
'guild_id': guild_id,
|
||||||
|
'guild_name': guild_name,
|
||||||
|
'joined_at': timestamp,
|
||||||
|
'username': username
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"[→] {username} moved from {old_state.get('channel_name', 'Unknown')} to {new_channel_name} in {guild_name}")
|
||||||
|
|
||||||
|
# User joined voice (wasn't in voice before)
|
||||||
|
elif channel_id is not None:
|
||||||
|
if channel_id not in self.channel_names:
|
||||||
|
self.get_voice_channels(guild_id)
|
||||||
|
|
||||||
|
channel_name = self.channel_names.get(channel_id, f'Channel-{channel_id}')
|
||||||
|
|
||||||
|
self.voice_states[state_key] = {
|
||||||
|
'channel_id': channel_id,
|
||||||
|
'channel_name': channel_name,
|
||||||
|
'guild_id': guild_id,
|
||||||
|
'guild_name': guild_name,
|
||||||
|
'joined_at': timestamp,
|
||||||
|
'username': username
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"[+] {username} joined {channel_name} in {guild_name}")
|
||||||
|
|
||||||
|
async def track_voice_activity(self):
|
||||||
|
# Connect to Discord gateway and track voice state changes
|
||||||
|
gateway_url = "wss://gateway.discord.gg/?v=9&encoding=json"
|
||||||
|
|
||||||
|
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:
|
||||||
|
await asyncio.sleep(heartbeat_interval / 1000)
|
||||||
|
if self.active:
|
||||||
|
await ws.send(json.dumps({"op": 1, "d": None}))
|
||||||
|
|
||||||
|
heartbeat_task = asyncio.create_task(heartbeat())
|
||||||
|
|
||||||
|
# Wait for READY
|
||||||
|
ready = False
|
||||||
|
while not ready:
|
||||||
|
msg = json.loads(await ws.recv())
|
||||||
|
if msg.get('t') == 'READY':
|
||||||
|
ready = True
|
||||||
|
print("Connected to Discord Gateway")
|
||||||
|
print("Tracking voice activity...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Track voice state updates
|
||||||
|
try:
|
||||||
|
while self.active:
|
||||||
|
msg = await ws.recv()
|
||||||
|
data = json.loads(msg)
|
||||||
|
|
||||||
|
event_type = data.get('t')
|
||||||
|
|
||||||
|
if event_type == 'VOICE_STATE_UPDATE':
|
||||||
|
self.handle_voice_state_update(data['d'])
|
||||||
|
|
||||||
|
# Also handle initial guild members in voice
|
||||||
|
elif event_type == 'GUILD_CREATE':
|
||||||
|
guild_id = data['d']['id']
|
||||||
|
# Only process if we should track this guild
|
||||||
|
if guild_id in self.exclude_guilds:
|
||||||
|
continue
|
||||||
|
if self.guild_ids and guild_id not in self.guild_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Process initial voice states
|
||||||
|
for voice_state in data['d'].get('voice_states', []):
|
||||||
|
voice_state['guild_id'] = guild_id
|
||||||
|
self.handle_voice_state_update(voice_state)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopping tracker...")
|
||||||
|
self.active = False
|
||||||
|
finally:
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
|
||||||
|
def save_logs(self):
|
||||||
|
# Save session logs to file
|
||||||
|
if not self.session_log and not self.voice_states:
|
||||||
|
print("No voice activity recorded")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Close any active sessions
|
||||||
|
timestamp = datetime.now()
|
||||||
|
for state_key, state in self.voice_states.items():
|
||||||
|
duration = (timestamp - state['joined_at']).total_seconds()
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'user_id': state_key.split(':')[0],
|
||||||
|
'username': state['username'],
|
||||||
|
'guild_id': state['guild_id'],
|
||||||
|
'guild_name': state.get('guild_name', 'Unknown'),
|
||||||
|
'channel_id': state['channel_id'],
|
||||||
|
'channel_name': state.get('channel_name', 'Unknown'),
|
||||||
|
'joined_at': state['joined_at'].isoformat(),
|
||||||
|
'left_at': timestamp.isoformat(),
|
||||||
|
'duration_seconds': duration,
|
||||||
|
'ongoing': True
|
||||||
|
}
|
||||||
|
|
||||||
|
self.session_log.append(session)
|
||||||
|
|
||||||
|
# Create output folder
|
||||||
|
if self.guild_ids and len(self.guild_ids) == 1:
|
||||||
|
folder_name = f"voice_activity_{self.guild_ids[0]}"
|
||||||
|
elif self.user_ids:
|
||||||
|
folder_name = f"voice_activity_user_{'_'.join(self.user_ids[:3])}"
|
||||||
|
else:
|
||||||
|
folder_name = "voice_activity_all"
|
||||||
|
|
||||||
|
output_folder = os.path.join(DATA_DIR, folder_name)
|
||||||
|
os.makedirs(output_folder, exist_ok=True)
|
||||||
|
|
||||||
|
timestamp_str = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
|
||||||
|
# Save raw JSON
|
||||||
|
json_file = os.path.join(output_folder, f"sessions_{timestamp_str}.json")
|
||||||
|
with open(json_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(self.session_log, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Generate summary stats
|
||||||
|
stats_file = os.path.join(output_folder, f"stats_{timestamp_str}.txt")
|
||||||
|
|
||||||
|
# Calculate stats
|
||||||
|
user_stats = defaultdict(lambda: {'total_time': 0, 'sessions': 0, 'channels': set(), 'guilds': set()})
|
||||||
|
channel_stats = defaultdict(lambda: {'total_time': 0, 'unique_users': set()})
|
||||||
|
guild_stats = defaultdict(lambda: {'total_time': 0, 'unique_users': set()})
|
||||||
|
|
||||||
|
for session in self.session_log:
|
||||||
|
user_id = session['user_id']
|
||||||
|
username = session['username']
|
||||||
|
channel_id = session['channel_id']
|
||||||
|
channel_name = session['channel_name']
|
||||||
|
guild_name = session.get('guild_name', 'Unknown')
|
||||||
|
duration = session['duration_seconds']
|
||||||
|
|
||||||
|
user_stats[username]['total_time'] += duration
|
||||||
|
user_stats[username]['sessions'] += 1
|
||||||
|
user_stats[username]['channels'].add(channel_name)
|
||||||
|
user_stats[username]['guilds'].add(guild_name)
|
||||||
|
|
||||||
|
channel_stats[f"{guild_name} > {channel_name}"]['total_time'] += duration
|
||||||
|
channel_stats[f"{guild_name} > {channel_name}"]['unique_users'].add(username)
|
||||||
|
|
||||||
|
guild_stats[guild_name]['total_time'] += duration
|
||||||
|
guild_stats[guild_name]['unique_users'].add(username)
|
||||||
|
|
||||||
|
with open(stats_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write("Voice Activity Statistics\n")
|
||||||
|
f.write("=" * 60 + "\n\n")
|
||||||
|
f.write(f"Total Sessions: {len(self.session_log)}\n")
|
||||||
|
f.write(f"Unique Users: {len(user_stats)}\n")
|
||||||
|
f.write(f"Servers Tracked: {len(guild_stats)}\n\n")
|
||||||
|
|
||||||
|
if len(guild_stats) > 1:
|
||||||
|
f.write("\nServer Activity:\n")
|
||||||
|
f.write("-" * 60 + "\n")
|
||||||
|
sorted_guilds = sorted(guild_stats.items(), key=lambda x: x[1]['total_time'], reverse=True)
|
||||||
|
for guild_name, stats in sorted_guilds:
|
||||||
|
hours = stats['total_time'] / 3600
|
||||||
|
f.write(f"{guild_name}:\n")
|
||||||
|
f.write(f" Total Time: {hours:.2f} hours\n")
|
||||||
|
f.write(f" Unique Users: {len(stats['unique_users'])}\n\n")
|
||||||
|
|
||||||
|
f.write("\nMost Active Users (by time):\n")
|
||||||
|
f.write("-" * 60 + "\n")
|
||||||
|
sorted_users = sorted(user_stats.items(), key=lambda x: x[1]['total_time'], reverse=True)
|
||||||
|
for username, stats in sorted_users[:10]:
|
||||||
|
hours = stats['total_time'] / 3600
|
||||||
|
f.write(f"{username}:\n")
|
||||||
|
f.write(f" Total Time: {hours:.2f} hours\n")
|
||||||
|
f.write(f" Sessions: {stats['sessions']}\n")
|
||||||
|
if len(stats['guilds']) > 1:
|
||||||
|
f.write(f" Servers: {', '.join(stats['guilds'])}\n")
|
||||||
|
f.write(f" Channels: {', '.join(stats['channels'])}\n\n")
|
||||||
|
|
||||||
|
f.write("\nChannel Activity:\n")
|
||||||
|
f.write("-" * 60 + "\n")
|
||||||
|
sorted_channels = sorted(channel_stats.items(), key=lambda x: x[1]['total_time'], reverse=True)
|
||||||
|
for channel_name, stats in sorted_channels:
|
||||||
|
hours = stats['total_time'] / 3600
|
||||||
|
f.write(f"{channel_name}:\n")
|
||||||
|
f.write(f" Total Time: {hours:.2f} hours\n")
|
||||||
|
f.write(f" Unique Users: {len(stats['unique_users'])}\n\n")
|
||||||
|
|
||||||
|
# Save detailed log
|
||||||
|
log_file = os.path.join(output_folder, f"detailed_log_{timestamp_str}.txt")
|
||||||
|
with open(log_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write("Detailed Voice Activity Log\n")
|
||||||
|
f.write("=" * 80 + "\n\n")
|
||||||
|
|
||||||
|
for session in sorted(self.session_log, key=lambda x: x['joined_at']):
|
||||||
|
ongoing = session.get('ongoing', False)
|
||||||
|
duration_min = session['duration_seconds'] / 60
|
||||||
|
|
||||||
|
f.write(f"User: {session['username']}\n")
|
||||||
|
f.write(f"Server: {session.get('guild_name', 'Unknown')}\n")
|
||||||
|
f.write(f"Channel: {session['channel_name']}\n")
|
||||||
|
f.write(f"Joined: {session['joined_at']}\n")
|
||||||
|
f.write(f"Left: {session['left_at']}")
|
||||||
|
if ongoing:
|
||||||
|
f.write(" (ongoing when tracking stopped)")
|
||||||
|
f.write("\n")
|
||||||
|
f.write(f"Duration: {duration_min:.1f} minutes\n")
|
||||||
|
f.write("-" * 80 + "\n\n")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"Logs saved to: {output_folder}")
|
||||||
|
print(f" - sessions_{timestamp_str}.json (raw data)")
|
||||||
|
print(f" - stats_{timestamp_str}.txt (summary)")
|
||||||
|
print(f" - detailed_log_{timestamp_str}.txt (full log)")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Voice Activity Tracker")
|
||||||
|
print("=" * 60)
|
||||||
|
print("Track voice activity with filters\n")
|
||||||
|
|
||||||
|
print("1. Track specific server(s)")
|
||||||
|
print("2. Track specific user(s)")
|
||||||
|
print("3. Track all servers")
|
||||||
|
print("4. Track all servers except some")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
choice = input("Choice: ").strip()
|
||||||
|
|
||||||
|
guild_ids = None
|
||||||
|
user_ids = None
|
||||||
|
exclude_guilds = None
|
||||||
|
|
||||||
|
if choice == '1':
|
||||||
|
# Track specific guilds
|
||||||
|
guild_input = input("\nEnter guild ID(s) (comma-separated for multiple): ").strip()
|
||||||
|
guild_ids = [g.strip() for g in guild_input.split(',') if g.strip()]
|
||||||
|
|
||||||
|
if not all(g.isdigit() for g in guild_ids):
|
||||||
|
print("Invalid guild ID(s)")
|
||||||
|
return
|
||||||
|
|
||||||
|
elif choice == '2':
|
||||||
|
# Track specific users
|
||||||
|
user_input = input("\nEnter user ID(s) (comma-separated for multiple): ").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
|
||||||
|
|
||||||
|
elif choice == '3':
|
||||||
|
# Track all servers
|
||||||
|
print("\nTracking all servers...")
|
||||||
|
|
||||||
|
elif choice == '4':
|
||||||
|
# Exclude specific servers
|
||||||
|
exclude_input = input("\nEnter guild ID(s) to EXCLUDE (comma-separated): ").strip()
|
||||||
|
exclude_guilds = [g.strip() for g in exclude_input.split(',') if g.strip()]
|
||||||
|
|
||||||
|
if not all(g.isdigit() for g in exclude_guilds):
|
||||||
|
print("Invalid guild ID(s)")
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Invalid choice")
|
||||||
|
return
|
||||||
|
|
||||||
|
tracker = VoiceActivityTracker(
|
||||||
|
guild_ids=guild_ids,
|
||||||
|
user_ids=user_ids,
|
||||||
|
exclude_guilds=exclude_guilds
|
||||||
|
)
|
||||||
|
|
||||||
|
# Show what we're tracking
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
if guild_ids:
|
||||||
|
print(f"Tracking {len(guild_ids)} specific server(s)")
|
||||||
|
for gid in guild_ids:
|
||||||
|
name = tracker.get_guild_info(gid)
|
||||||
|
print(f" - {name}")
|
||||||
|
elif user_ids:
|
||||||
|
print(f"Tracking {len(user_ids)} specific user(s) across all servers")
|
||||||
|
elif exclude_guilds:
|
||||||
|
print(f"Tracking all servers except {len(exclude_guilds)}")
|
||||||
|
else:
|
||||||
|
print("Tracking all servers")
|
||||||
|
|
||||||
|
print("\nPress Ctrl+C to stop tracking and save logs\n")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(tracker.track_voice_activity())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nStopping tracker...")
|
||||||
|
finally:
|
||||||
|
tracker.save_logs()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
311
scripts/voice_call_keeper.py
Executable file
311
scripts/voice_call_keeper.py
Executable file
@@ -0,0 +1,311 @@
|
|||||||
|
# discord_tools/scripts/voice_call_keeper.py
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
import websockets
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 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
|
||||||
|
from discord_tools.utils.api_utils import make_discord_request
|
||||||
|
|
||||||
|
class VoiceCallKeeper:
|
||||||
|
def __init__(self):
|
||||||
|
self.active = True
|
||||||
|
self.current_channel_id = None
|
||||||
|
self.current_guild_id = None
|
||||||
|
self.session_id = None
|
||||||
|
self.token = None
|
||||||
|
self.endpoint = None
|
||||||
|
self.my_user_id = None
|
||||||
|
self.start_time = None
|
||||||
|
self.total_reconnects = 0
|
||||||
|
|
||||||
|
async def find_current_voice_channel(self, ws):
|
||||||
|
# Wait for READY and get current voice state
|
||||||
|
ready = False
|
||||||
|
current_voice = None
|
||||||
|
|
||||||
|
while not ready:
|
||||||
|
msg = json.loads(await ws.recv())
|
||||||
|
|
||||||
|
if msg.get('t') == 'READY':
|
||||||
|
ready_data = msg['d']
|
||||||
|
self.my_user_id = ready_data['user']['id']
|
||||||
|
|
||||||
|
# Check private calls
|
||||||
|
private_calls = ready_data.get('private_calls', [])
|
||||||
|
for call in private_calls:
|
||||||
|
# Check if we're in this call
|
||||||
|
voice_states = call.get('voice_states', [])
|
||||||
|
for vs in voice_states:
|
||||||
|
if vs.get('user_id') == self.my_user_id:
|
||||||
|
current_voice = {
|
||||||
|
'channel_id': call['channel_id'],
|
||||||
|
'guild_id': None,
|
||||||
|
'type': 'DM Call'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
if current_voice:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check guild voice states
|
||||||
|
if not current_voice:
|
||||||
|
guilds = ready_data.get('guilds', [])
|
||||||
|
for guild in guilds:
|
||||||
|
voice_states = guild.get('voice_states', [])
|
||||||
|
for vs in voice_states:
|
||||||
|
if vs.get('user_id') == self.my_user_id and vs.get('channel_id'):
|
||||||
|
current_voice = {
|
||||||
|
'channel_id': vs['channel_id'],
|
||||||
|
'guild_id': guild['id'],
|
||||||
|
'type': 'Server VC'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
if current_voice:
|
||||||
|
break
|
||||||
|
|
||||||
|
ready = True
|
||||||
|
|
||||||
|
return current_voice
|
||||||
|
|
||||||
|
async def maintain_voice_connection(self, channel_id=None, guild_id=None, auto_join=False):
|
||||||
|
# Connect to gateway and maintain voice connection with auto-reconnect
|
||||||
|
gateway_url = "wss://gateway.discord.gg/?v=9&encoding=json"
|
||||||
|
|
||||||
|
# Set start time on first connection
|
||||||
|
if self.start_time is None:
|
||||||
|
self.start_time = datetime.now()
|
||||||
|
|
||||||
|
while self.active:
|
||||||
|
try:
|
||||||
|
await self._connect_and_run(gateway_url, channel_id, guild_id, auto_join)
|
||||||
|
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)
|
||||||
|
# After reconnect, don't auto-join again, use stored IDs
|
||||||
|
auto_join = False
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nDisconnecting...")
|
||||||
|
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)
|
||||||
|
auto_join = False
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
async def _connect_and_run(self, gateway_url, channel_id, guild_id, auto_join):
|
||||||
|
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())
|
||||||
|
|
||||||
|
try:
|
||||||
|
# If auto-join, find current voice channel
|
||||||
|
if auto_join:
|
||||||
|
print("Detecting current voice channel...")
|
||||||
|
current_voice = await self.find_current_voice_channel(ws)
|
||||||
|
|
||||||
|
if not current_voice:
|
||||||
|
print("Not currently in any voice channel/call")
|
||||||
|
return
|
||||||
|
|
||||||
|
channel_id = current_voice['channel_id']
|
||||||
|
guild_id = current_voice['guild_id']
|
||||||
|
print(f"Found: {current_voice['type']}")
|
||||||
|
|
||||||
|
# Store for reconnections
|
||||||
|
self.current_channel_id = channel_id
|
||||||
|
self.current_guild_id = guild_id
|
||||||
|
else:
|
||||||
|
# Wait for READY
|
||||||
|
ready = False
|
||||||
|
while not ready:
|
||||||
|
msg = json.loads(await ws.recv())
|
||||||
|
if msg.get('t') == 'READY':
|
||||||
|
ready = True
|
||||||
|
self.my_user_id = msg['d']['user']['id']
|
||||||
|
|
||||||
|
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)")
|
||||||
|
|
||||||
|
# Use stored or provided IDs
|
||||||
|
if self.current_channel_id is None:
|
||||||
|
self.current_channel_id = channel_id
|
||||||
|
self.current_guild_id = guild_id
|
||||||
|
|
||||||
|
# Join/maintain voice channel/call
|
||||||
|
if self.total_reconnects == 0:
|
||||||
|
print(f"Maintaining voice connection...")
|
||||||
|
print("Call keeper active - Press Ctrl+C to disconnect")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
voice_state_update = {
|
||||||
|
"op": 4,
|
||||||
|
"d": {
|
||||||
|
"guild_id": self.current_guild_id,
|
||||||
|
"channel_id": self.current_channel_id,
|
||||||
|
"self_mute": False,
|
||||||
|
"self_deaf": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(voice_state_update))
|
||||||
|
|
||||||
|
# Monitor voice state and keep connection alive
|
||||||
|
while self.active:
|
||||||
|
msg = await ws.recv()
|
||||||
|
data = json.loads(msg)
|
||||||
|
|
||||||
|
event_type = data.get('t')
|
||||||
|
|
||||||
|
# Track voice state changes
|
||||||
|
if event_type == 'VOICE_STATE_UPDATE':
|
||||||
|
voice_data = data['d']
|
||||||
|
# Check if it's related to our channel
|
||||||
|
if voice_data.get('channel_id') == self.current_channel_id or voice_data.get('user_id') == self.my_user_id:
|
||||||
|
user_id = voice_data.get('user_id')
|
||||||
|
member = voice_data.get('member', {})
|
||||||
|
user = member.get('user', {})
|
||||||
|
username = user.get('username', 'Unknown')
|
||||||
|
|
||||||
|
if voice_data.get('channel_id') is None and user_id != self.my_user_id:
|
||||||
|
print(f"[-] {username} left")
|
||||||
|
elif voice_data.get('channel_id') == self.current_channel_id and user_id != self.my_user_id:
|
||||||
|
elapsed = (datetime.now() - self.start_time).total_seconds()
|
||||||
|
print(f"[+] {username} joined (running {elapsed:.0f}s)")
|
||||||
|
|
||||||
|
# Voice server update
|
||||||
|
elif event_type == 'VOICE_SERVER_UPDATE':
|
||||||
|
self.token = data['d'].get('token')
|
||||||
|
self.endpoint = data['d'].get('endpoint')
|
||||||
|
if self.total_reconnects == 0:
|
||||||
|
print(f"[✓] Connection established")
|
||||||
|
else:
|
||||||
|
print(f"[✓] Voice connection re-established")
|
||||||
|
|
||||||
|
elif event_type == 'CALL_CREATE':
|
||||||
|
print("[📞] Call created")
|
||||||
|
|
||||||
|
elif event_type == 'CALL_UPDATE':
|
||||||
|
ringing = data['d'].get('ringing', [])
|
||||||
|
if ringing:
|
||||||
|
print(f"[📞] Ringing: {len(ringing)} user(s)")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
|
||||||
|
# Only leave voice if we're truly shutting down
|
||||||
|
if not self.active:
|
||||||
|
try:
|
||||||
|
leave_voice = {
|
||||||
|
"op": 4,
|
||||||
|
"d": {
|
||||||
|
"guild_id": self.current_guild_id,
|
||||||
|
"channel_id": None,
|
||||||
|
"self_mute": False,
|
||||||
|
"self_deaf": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(leave_voice))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
total_time = (datetime.now() - self.start_time).total_seconds()
|
||||||
|
hours = total_time / 3600
|
||||||
|
minutes = (total_time % 3600) / 60
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"Duration: {int(hours)}h {int(minutes)}m")
|
||||||
|
print(f"Total reconnections: {self.total_reconnects}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Voice Call Keeper")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
print("1. Auto-join current call/VC")
|
||||||
|
print("2. Join specific channel")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
choice = input("Choice: ").strip()
|
||||||
|
|
||||||
|
if choice == '1':
|
||||||
|
# Auto-join current voice
|
||||||
|
keeper = VoiceCallKeeper()
|
||||||
|
asyncio.run(keeper.maintain_voice_connection(auto_join=True))
|
||||||
|
|
||||||
|
elif choice == '2':
|
||||||
|
# Manual join
|
||||||
|
channel_id = input("\nChannel ID: ").strip()
|
||||||
|
|
||||||
|
if not channel_id.isdigit():
|
||||||
|
print("Invalid channel ID")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ask if it's a server VC
|
||||||
|
is_server = input("Server VC? (y/n): ").strip().lower()
|
||||||
|
|
||||||
|
guild_id = None
|
||||||
|
if is_server == 'y':
|
||||||
|
guild_id = input("Guild ID: ").strip()
|
||||||
|
if not guild_id.isdigit():
|
||||||
|
print("Invalid guild ID")
|
||||||
|
return
|
||||||
|
|
||||||
|
keeper = VoiceCallKeeper()
|
||||||
|
asyncio.run(keeper.maintain_voice_connection(channel_id, guild_id=guild_id))
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Invalid choice")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
utils/__init__.py
Executable file
0
utils/__init__.py
Executable file
BIN
utils/__pycache__/__init__.cpython-313.pyc
Executable file
BIN
utils/__pycache__/__init__.cpython-313.pyc
Executable file
Binary file not shown.
BIN
utils/__pycache__/__init__.cpython-314.pyc
Executable file
BIN
utils/__pycache__/__init__.cpython-314.pyc
Executable file
Binary file not shown.
BIN
utils/__pycache__/api_utils.cpython-313.pyc
Executable file
BIN
utils/__pycache__/api_utils.cpython-313.pyc
Executable file
Binary file not shown.
BIN
utils/__pycache__/api_utils.cpython-314.pyc
Executable file
BIN
utils/__pycache__/api_utils.cpython-314.pyc
Executable file
Binary file not shown.
61
utils/api_utils.py
Executable file
61
utils/api_utils.py
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
# discord_tools/utils/api_utils.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 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_API_BASE_URL, DISCORD_TOKEN, RATE_LIMIT_ATTEMPTS, RATE_LIMIT_DELAY, ERROR_MESSAGES
|
||||||
|
|
||||||
|
def make_discord_request(method, endpoint, **kwargs):
|
||||||
|
"""
|
||||||
|
Make a request to the Discord API with built-in rate limiting and error handling.
|
||||||
|
|
||||||
|
:param method: HTTP method (e.g., 'GET', 'POST', 'DELETE')
|
||||||
|
:param endpoint: API endpoint (e.g., '/users/@me')
|
||||||
|
:param kwargs: Additional arguments to pass to the requests function
|
||||||
|
:return: Response object or None if the request failed
|
||||||
|
"""
|
||||||
|
url = f"{DISCORD_API_BASE_URL}{endpoint}"
|
||||||
|
headers = {
|
||||||
|
"Authorization": DISCORD_TOKEN,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
headers.update(kwargs.get('headers', {}))
|
||||||
|
kwargs['headers'] = headers
|
||||||
|
|
||||||
|
for attempt in range(RATE_LIMIT_ATTEMPTS):
|
||||||
|
try:
|
||||||
|
response = requests.request(method, url, **kwargs)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
if e.response.status_code == 429: # Rate limited
|
||||||
|
retry_after = e.response.json().get('retry_after', RATE_LIMIT_DELAY)
|
||||||
|
print(f"Rate limited. Retrying in {retry_after} seconds...")
|
||||||
|
time.sleep(retry_after)
|
||||||
|
else:
|
||||||
|
print(f"HTTP Error: {e}")
|
||||||
|
return None
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"Request failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
print(ERROR_MESSAGES["rate_limit_exceeded"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_user_id():
|
||||||
|
"""
|
||||||
|
Get the user ID of the authenticated user.
|
||||||
|
|
||||||
|
:return: User ID as a string, or None if the request failed
|
||||||
|
"""
|
||||||
|
response = make_discord_request('GET', '/users/@me')
|
||||||
|
if response:
|
||||||
|
return response.json()['id']
|
||||||
|
return None
|
||||||
0
utils/file_utils.py
Executable file
0
utils/file_utils.py
Executable file
Reference in New Issue
Block a user