Compare commits

...

10 Commits

2 changed files with 217 additions and 93 deletions

View File

@ -6,7 +6,7 @@ This script uses Discord's undocumented User API, which has no official public d
**Self-botting is explicitly forbidden by the Discord Trust & Safety Team and can result in an account termination if found**. Therefore, it's not unlikely that **using this script may incur a ban on your account.** Discord's explicit condemnation of self-botting can be found [here](https://support.discord.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-).
# About
`purge_discord.py` is a Discord self-botting Python script that allows the user to programmatically mass-delete all of the messages that they sent in a given Discord channel, including servers, DMs, & group chats via the Discord User API. It is primarily designed to be ran from a command-line, with the URL or ID of a channel being passed to the script as arguments.
`purge_discord.py` is a Discord self-botting Python script that allows the user to programmatically mass-delete all of the messages that they sent in a given Discord server, including DMs & group chats, via the Discord User API. It is primarily designed to be ran from a command-line, with the URL of a channel being passed to the script as arguments.
The purpose of the script is to enhance the user's control over their private information on Discord. Discord is not a social media platform with strong privacy practices, and I firmly believe that an individual should have the right to remove their data from any given platform quickly & easily at will. At present, there is no way to delete all of your messages from Discord. Not even deleting your account will remove your messages from the platform, and they will still be visible permanently. Deleting your account is in this sense worse for your privacy, as you lose control over your messages; Once you delete your account, you will permanently lose the ability to delete any of your messages, meaning that any information you revealed in those messages is now permanently public information.
@ -40,17 +40,17 @@ To get usage information, you can run the script with no arguments. The usage in
```
The output should be as follows:
```
Usage: ./purge_discord.py [OPTION]... [ARGUMENT]...
Delete all the user's messages in the given Discord channel.
The channel may be specified using one of the following options:
-i, --channel-id delete messages in the channel corresponding to the supplied ID
-u, --channel-url delete messages in the channel corresponding to the supplied URL
usage: purge_discord.py [-h] -u CHANNEL_URL
Script to delete all a user's messages in a given Discord server
options:
-h, --help show this help message and exit
-u, --channel-url CHANNEL_URL
URL of a channel in the server
```
The script can be ran either by supplying the channel ID or the channel URL. To get the ID of a channel, right-click on it in Discord and click the "Copy channel ID" option in the context menu. The ID can then be passed to the script as follows:
```bash
./purge_discord.py -i 1234567890123456789
```
Alternatively, the script can be ran by supplying the channel URL. This is the URL that is shown in the address bar of your browser when you navigate to that channel in the web version of the Discord application. The URL can be passed to the script as follows:
The URL is the one that is shown in the address bar of your browser when you navigate to that channel in the web version of the Discord application. The URL can be passed to the script as follows:
```bash
./purge_discord.py -u https://discord.com/channels/@me/1234567890123456789
```

View File

@ -1,100 +1,224 @@
#!/bin/python3
import asyncio, aiohttp, os, sys, json
from asyncio.base_events import time
from dotenv import load_dotenv
import argparse
BASE_URL = "https://discord.com/api/v9/"
# method to print out the usage instructions if the program was called incorrectly
def usage():
sys.exit("Usage: ./purge_discord.py [OPTION]... [ARGUMENT]...\n"
"Delete all the user's messages in the given Discord channel.\n"
"The channel may be specified using one of the following options:\n"
"\t-i, --channel-id delete messages in the channel corresponding to the supplied ID\n"
"\t-u, --channel-url delete messages in the channel corresponding to the supplied URL")
def read_token():
"""
Reads the user's Discord token from the environment file.
File should be in the format: `DISCORD_TOKEN=<insert_discord_token_here>`.
Returns:
str: Discord token specified in environment file.
"""
async def main():
# parsing command-line arguments
if len(sys.argv) == 3:
if sys.argv[1] == "-i" or sys.argv[1] == "--channel-id":
channel = sys.argv[2]
elif sys.argv[1] == "-u" or sys.argv[1] == "--channel-url":
channel = sys.argv[2].split("/")[-1] # parsing the channel ID from the URL by splitting it on `/` and taking the last segment
else:
usage()
else:
usage()
# reading Discord token from the .env file. should be in the format `DISCORD_TOKEN=<insert_discord_token_here>`
load_dotenv()
token = os.getenv("DISCORD_TOKEN")
if not token:
sys.exit("DISCORD_TOKEN environment variable is not set")
raise Exception("DISCORD_TOKEN environment variable is not set")
host = "https://discord.com/api/v10"
headers = {"authorization": token}
offset = 0 # messages are received from the API in batches of 25, so need to keep track of the offset when requesting to make sure no old messages are received
to_delete = [] # list of messages to be deleted
return token
def split_channel_url(channel_url):
"""
Split the channel URL into the guild ID and channel ID.
Note that 'guild' is just Discord's internal name for 'server'.
Args:
channel_url (str): the channel URL.
Returns:
string: the guild ID.
string: the channel ID.
"""
split_string = channel_url.split("/")
guild_id = split_string[-2]
channel_id = split_string[-1]
return guild_id, channel_id
def get_guild_url(guild_id, channel_id):
"""
Get the guild URL based off the guild ID and channel ID.
Args:
guild_id (str): the guild ID.
channel_id (str): the channel ID.
Returns:
str: the guild URL.
"""
if (guild_id == "@me"):
guild_url = "https://discord.com/api/v9/channels/" + channel_id
else:
guild_url = "https://discord.com/api/v9/guilds/" + guild_id
return guild_url
def get_headers(token):
"""
Returns a dictionary object containing the headers with which to make API requests, including the authorisation token.
Args:
token (string): the user's Discord token.
Returns:
dict: A dictionary containing the headers.
"""
headers = {
"authorization": token,
}
return headers
async def get_user_id(session, headers):
"""
Fetches the Discord user ID of the user's whose Discord token has been supplied.
Args:
session (aiohttp.ClientSession): the session to make the request with.
headers (dictionary): the headers with which to make the GET request.
Returns:
string: the (numerical) user ID of the Discord user.
"""
async with session.request("GET", BASE_URL + "users/@me", headers=headers) as response:
user_id = (await response.json())["id"]
return user_id
async def get_all_messages(session, headers, guild_url, user_id):
"""
Get a list of all of the user's messages in the guild.
Args:
session (aiohttp.ClientSession): the session to make the requests with.
headers (dictionary): the headers with which to make the requests.
guild_url (str): the URL of the guild from which to fetch all the messages.
user_id (str): the ID of the user's whose messages are being fetched.
Returns:
list: a list of all the user's messages in the guild.
"""
messages = []
# The Discord API returns messages in batches of 25, so an offset is used to define from which starting point to return the previous 25 messages.
offset = 0
while True:
request_url = guild_url + "/messages/search?author_id=" + user_id + "&offset=" + str(offset)
async with session.request("GET", request_url, headers=headers) as response:
# If the channel is not yet indexed, may be asked to try again later, so loop until messages are returned.
while True:
# Break if the request returned a list (empty or otherwise) of messages.
if "messages" in (await response.json()):
batch = (await response.json())["messages"]
break
else:
try:
# Assuming that a "Try again later" message was received if no message list was returned
time_to_wait = (await response.json())["retry_after"]
print("Channel is not yet indexed. Waiting for " + str(time_to_wait) + "s as requested by the Discord API")
await asyncio.sleep(time_to_wait)
except KeyError:
# If some other message was received and no interval was specified in the response, throw an exception.
raise Exception("Unexpected JSON response received from Discord after trying to search for messages. Actual JSON response: " + json.dumps(await response.json(), indent=4))
# If the batch is not empty, add the messages in batch to the list of messages.
if batch:
messages += batch
offset += 25
# if the batch is empty, there are no more messages to be added to the to_delete list so breaking out of the search loop
else:
break
return messages
async def delete_messages(session, headers, messages):
"""
Iterates over a list of messages and deletes them.
Args:
session (aiohttp.ClientSession): the session to make the requests with.
headers (dictionary): the headers with which to make the requests.
messages (list): a list of Discord messages.
Returns:
None
"""
print("Deleting " + str(len(messages)) + " message(s)")
num_deleted = 0 # The number of messages that have been deleted.
for message in messages:
message_url = BASE_URL + "channels/" + message[0]["channel_id"] + "/messages/" + message[0]["id"]
# Do nothing if the message is a system message (has type = 1).
if message[0]["type"] == 1:
break
# Discord may reject a DELETE request and say to try again, so loop until the message is successfully deleted (or until a break due to a forbidden message).
while True:
async with session.request("DELETE", message_url, headers=headers) as response:
# If successful status code returned, printing success message and breaking out of loop
if 200 <= response.status <= 299:
num_deleted += 1
print("Successfully deleted message " + str(num_deleted) + " of " + str(len(messages)))
break;
# Else if "forbidden" status returned, giving up on that message and continuing.
elif response.status == 403:
print("Failed to delete message: Error 403 " + (await response.json())["message"])
break
# Else if "Too many requests" status returned, waiting for the amount of time specified.
elif response.status == 429:
retry_after = (await response.json())["retry_after"]
print("Rate limited by Discord. Waiting for " + str(retry_after) + "s")
await asyncio.sleep(retry_after)
# Otherwise, printing out JSON response and throwing an exception
else:
print(response.status)
raise Exception("Unexpected HTTP status code received. Actual response: " + json.dumps(await response.json(), indent=4))
async def main():
parser = argparse.ArgumentParser(description="Script to delete all a user's messages in a given Discord server")
parser.add_argument("-u", "--channel-url", type=str, help="URL of a channel in the server", required=True)
args=parser.parse_args()
token = read_token()
headers = get_headers(token)
guild_id, channel_id = split_channel_url(args.channel_url)
guild_url = get_guild_url(guild_id, channel_id)
# starting asynchronous context manager to keep all HTTP requests in the one session
async with aiohttp.ClientSession() as session:
# getting the user ID pertaining to the Discord token that the script is using
async with session.request("GET", host + "/users/@me", headers=headers) as response:
try:
user = (await response.json())["id"]
except KeyError:
sys.exit("User ID not found in JSON response. Actual JSON response: " + json.dumps(await response.json(), indent=4))
# looping to fill up list of messages by requesting batches of messages from the API and adding them to the list
while True:
# searching for the next 25 messages from the user in question from the Discord API (Discord returns search results in batches of 25)
async with session.request("GET", host + "/channels/" + str(channel) + "/messages/search?author_id=" + str(user) + "&offset=" + str(offset), headers=headers) as response:
# if the channel is not yet indexed, looping until Discord indexes it
while True:
# if the search returned an array (empty or otherwise) of messages, breaking
if "messages" in (await response.json()):
batch = (await response.json())["messages"]
break
# assuming that a "Try again later" message was received if not messages, so trying to wait the requested interval.
# if some other message was received and no interval was in the response, exiting with an error message
else:
try:
print("Channel is not yet indexed. Waiting for " + str((await response.json())["retry_after"]) + "s as requested by the Discord API")
await asyncio.sleep((await response.json())["retry_after"])
except KeyError:
sys.exit("Unexpected JSON response received from Discord after trying to search for messages. Actual JSON response: " + json.dumps(await response.json(), indent=4))
# if the batch is not empty, adding the messages in batch to the list of messages to be deleted
if batch:
to_delete += batch
offset += 25
# if the batch is empty, there are no more messages to be added to the to_delete list so breaking out of the search loop
else: break
deleted_messages = 0 # count of the number of messages that have been deleted
print("Deleting " + str(len(to_delete)) + " message(s)")
# looping through messages in to_delete list and sending the request to delete them one by one
for message in to_delete:
# looping infinitely until message is successfully deleted
while True:
async with session.request("DELETE", host + "/channels/" + channel + "/messages/" + message[0]["id"], headers=headers) as response:
# if successful status returned, printing success message and breaking out of loop
if 200 <= response.status <= 299:
deleted_messages += 1
print("Successfully deleted message " + str(deleted_messages) + " of " + str(len(to_delete)))
break;
# else if "Too many requests" status returned, waiting for the amount of time specified
elif response.status == 429:
print("Rate limited by Discord. Waiting for " + str((await response.json())["retry_after"]) + "s")
await asyncio.sleep((await response.json())["retry_after"])
# otherwise, printing out json response and aborting
else:
sys.exit("Unexpected HTTP status code received. Actual response: " + json.dumps(response.json(), indent=4))
user_id = await get_user_id(session, headers)
to_delete = await (get_all_messages(session, headers, guild_url, user_id))
await delete_messages(session, headers, to_delete)
if __name__ == "__main__":