Compare commits
10 Commits
9e1d5bd976
...
6f83dc50f4
Author | SHA1 | Date | |
---|---|---|---|
6f83dc50f4 | |||
8cabbb8dae | |||
3fb6fd32eb | |||
0122a5509d | |||
1e93aa2534 | |||
fe2c80d8d6 | |||
3e29517997 | |||
b3927cc9d0 | |||
8e9007ffde | |||
50726ee888 |
22
README.md
22
README.md
@ -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
|
||||
```
|
||||
|
288
purge_discord.py
288
purge_discord.py
@ -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__":
|
||||
|
Reference in New Issue
Block a user