from fastcore.test import *
import tempfilecordslite API
Discord has three APIs. The REST API handles actions such as sending a message, listing channels, or fetching history. The Gateway API pushes real-time events over a websocket. The Voice API streams audio over UDP.
The REST API needs a bot token from the Discord Developer Portal:
- Create an application.
- Under “Bot”, add a bot and copy its token.
- Under “OAuth2”, generate an invite URL with the
botscope and the permissions the bot needs, and open it to invite the bot to your server.
DiscordClient wraps an httpx2 client with Discord’s base URL (https://discord.com/api/v10) and the auth header set.
DiscordClient
def DiscordClient(
token:NoneType=None, # Bot token, defaulting to `DISCORD_BOT_TOKEN`
user_token:NoneType=None, # User token, defaulting to `DISCORD_USER_TOKEN`. Used by `use_user=True` calls, and by every call when there is no bot token
name:str='cordslite', # Client name for the User-Agent header
ver:str='0.1', # Client version for the User-Agent header
):Async client for Discord’s REST API
DiscordClient reads DISCORD_BOT_TOKEN from the environment when it gets no token. Tokens are stripped of surrounding whitespace, since a pasted token often carries a trailing newline, and a blank token counts as absent.
dc = DiscordClient()pasted = DiscordClient(token=' abc\n', user_token='\txyz ')
test_eq((pasted.token, pasted.user_token), ('abc', 'xyz'))
test_is(DiscordClient(token='x', user_token=' \n').user_token, None)REST endpoints
Every REST call goes through _req. On 429 Too Many Requests it waits for Retry-After seconds and retries. Any other non-2xx response raises DiscordError with Discord’s error code, message, and HTTP status.
DiscordError
def DiscordError(
code, msg, status
):Common base class for all non-exit exceptions.
Discord’s REST endpoints return JSON with many fields. DiscordObject wraps the raw dict instead of declaring a property per field. Fields read as obj.name or obj['name']. __dir__ lists them for tab completion in notebooks. New fields Discord adds are available without a code change.
DiscordObject
def DiscordObject(
data, client
):dict subclass that also provides access to keys as attrs, and has a pretty markdown repr
Discord’s hierarchy is guild, channel, message. A guild (server) contains channels. A channel contains messages. Each level has its own REST endpoints:
GET /guilds/{id}GET /guilds/{id}/channelsGET /channels/{id}/messagesPOST /channels/{id}/messages
Guild, Channel, and Message subclass DiscordObject and add a __repr__.
Guild
def Guild(
data, client
):dict subclass that also provides access to keys as attrs, and has a pretty markdown repr
@patch from fastcore adds methods to a class after its definition. Each method sits beside its explanation and can be tested right after you write it.
gid = '1493461895615873044'
gld = await dc.guild(gid)
gldGuild(id=1493461895615873044, name='AAI-test-dev')Every channel has a numeric type. ChannelType names them.
Channels
def Channels(
*args, **kwargs
):Built-in mutable sequence.
If no argument is given, the constructor creates a new empty list. The argument must be an iterable if specified.
Channel
def Channel(
data, client
):dict subclass that also provides access to keys as attrs, and has a pretty markdown repr
chs = await gld.channels()
chs| ID | Name | Type |
|---|---|---|
| 1493461896139903026 | Text Channels | 4 |
| 1493461896139903027 | Voice Channels | 4 |
| 1493461896139903028 | general | 0 |
| 1493461896139903029 | General | 2 |
is_text includes announcement channels. They take messages like text channels do. parent is the channel a thread was opened in.
[(c.name, ChannelType(c.type).name, c.is_text) for c in chs][('Text Channels', 'GUILD_CATEGORY', False),
('Voice Channels', 'GUILD_CATEGORY', False),
('general', 'GUILD_TEXT', True),
('General', 'GUILD_VOICE', False)]
dc.guilds() lists every guild the bot has joined, from /users/@me/guilds. That endpoint returns partial guild objects: id, name, owner, and permissions. Use it when you do not know the guild id up front.
DiscordClient.guilds
async def guilds(
limit:fastcore.xtras.Unset=UNSET
):List the guilds the bot is a member of
Guilds
def Guilds(
*args, **kwargs
):Built-in mutable sequence.
If no argument is given, the constructor creates a new empty list. The argument must be an iterable if specified.
glds = await dc.guilds()
assert any(g.id == gid for g in glds)
glds| ID | Name |
|---|---|
| 1200111522916094103 | Answer.ai |
| 1493461895615873044 | AAI-test-dev |
chid = '1493461896139903028'
ch = await dc.channel(chid)
chChannel(id=1493461896139903028, name='general', type=0)Discord returns messages newest first. messages reverses them so the table reads in chronological order. Reading message content needs the MESSAGE_CONTENT privileged intent, enabled in the Developer Portal. Without it, content is empty for messages that neither come from the bot nor mention it.
Channel.messages
async def messages(
limit:int=50, before:fastcore.xtras.Unset=UNSET, after:fastcore.xtras.Unset=UNSET,
around:fastcore.xtras.Unset=UNSET, use_user:bool=False
):Fetch up to limit channel messages, oldest first. Discord allows at most 100. before, after, and around are mutually exclusive message IDs or Messages, not dates.
Messages
def Messages(
*args, **kwargs
):Built-in mutable sequence.
If no argument is given, the constructor creates a new empty list. The argument must be an iterable if specified.
Message
def Message(
data, dobj
):dict subclass that also provides access to keys as attrs, and has a pretty markdown repr
msgs = await ch.messages(5)
msgs| ID | Author | Content | Date |
|---|---|---|---|
| 1549135343352152115 | share2self | I'm replying to myself 🤓 | 2026-09-14 |
| 1549135347856834581 | share2self | Here is a file! | 2026-09-14 |
| 1549136213334823140 | share2self | Hi, from Solveit! | 2026-09-14 |
| 1549136214496514101 | share2self | I'm replying to myself 🤓 | 2026-09-14 |
| 1549136217679986791 | share2self | Here is a file! | 2026-09-14 |
Message.around
async def around(
limit:int=11
):Fetch messages around this message in the same channel/thread.
Message.after
async def after(
limit:int=5
):Fetch messages after this message in the same channel/thread.
Message.before
async def before(
limit:int=5
):Fetch messages before this message in the same channel/thread.
gld.url, ch.url, msgs[0].url('https://discord.com/channels/1493461895615873044',
'https://discord.com/channels/1493461895615873044/1493461896139903028',
'https://discord.com/channels/1493461895615873044/1493461896139903028/1549135343352152115')
Guild search uses snowflake IDs for date filtering (min_id/max_id), but we autoconvert 'YYYY-MM-DD' strings for convenience for before/after.
Guild.search
async def search(
*, content:fastcore.xtras.Unset=UNSET, author_id:fastcore.xtras.Unset=UNSET,
channel_id:fastcore.xtras.Unset=UNSET, mentions:fastcore.xtras.Unset=UNSET, has:fastcore.xtras.Unset=UNSET,
before:fastcore.xtras.Unset=UNSET, after:fastcore.xtras.Unset=UNSET, pinned:fastcore.xtras.Unset=UNSET,
sort_by:fastcore.xtras.Unset=UNSET, sort_order:fastcore.xtras.Unset=UNSET, offset:fastcore.xtras.Unset=UNSET,
limit:fastcore.xtras.Unset=UNSET, use_user:bool=False, nothread:bool=True
):Search guild messages. before/after accept ‘YYYY-MM-DD’ strings or snowflake IDs.
date2snowflake
def date2snowflake(
date_str
):Convert ‘YYYY-MM-DD’ to a Discord snowflake ID
msgs = await gld.search(after='2026-02-16', limit=5)
msgs| ID | Author | Content | Date |
|---|---|---|---|
| 1549136217679986791 | share2self | Here is a file! | 2026-09-14 |
| 1549136214496514101 | share2self | I'm replying to myself 🤓 | 2026-09-14 |
| 1549136213334823140 | share2self | Hi, from Solveit! | 2026-09-14 |
| 1549135347856834581 | share2self | Here is a file! | 2026-09-14 |
| 1549135343352152115 | share2self | I'm replying to myself 🤓 | 2026-09-14 |
find_member searches the guild’s members by username, nickname, or display name through Discord’s member search endpoint and returns the first match’s user id. Pass the result to search as author_id.
Guild.find_member
async def find_member(
name
):Search guild members by name/nick, return first match’s user ID or None
uid = await gld.find_member('nate.dawgg')
assert uid
await gld.search(author_id=uid, limit=5)| ID | Author | Content | Date |
|---|---|---|---|
| 1545428526155698319 | nate.dawgg | !stop | 2026-09-04 |
| 1545428448858607626 | nate.dawgg | !record | 2026-09-04 |
| 1545391205704138792 | nate.dawgg | @share2self you there? | 2026-09-04 |
| 1545391018268958750 | nate.dawgg | Test | 2026-09-04 |
| 1545250187574910978 | nate.dawgg | @share2self Hi | 2026-09-04 |
send posts a JSON body. reply_id makes the message a reply to an existing one. With files it switches to multipart/form-data: Discord expects the message JSON in a payload_json field and one files[n] field per file.
Channel.send
async def send(
content:str='', files:NoneType=None, reply_id:NoneType=None
):Send a message with optional file attachments
msg = await ch.send('Hi, from Solveit!')
msgMessage(id=1549136518638075925, author='share2self', content='Hi, from Solveit!')reply_msg = await ch.send("I'm replying to myself 🤓", reply_id=msg.id)
reply_msgMessage(id=1549136520248827974, author='share2self', content="I'm replying to myself 🤓")Discord shows the indicator for about ten seconds. A bot that takes longer to answer calls this again.
await ch.typing()await msg.channelChannel(id=1493461896139903028, name='general', type=0)msg = await ch.send('Here is a file!', files=['../README.md'])
msgMessage(id=1549136523583295509, author='share2self', content='Here is a file!')Channel.search
async def search(
*, content:fastcore.xtras.Unset=UNSET, author_id:fastcore.xtras.Unset=UNSET, mentions:fastcore.xtras.Unset=UNSET,
has:fastcore.xtras.Unset=UNSET, before:fastcore.xtras.Unset=UNSET, after:fastcore.xtras.Unset=UNSET,
pinned:fastcore.xtras.Unset=UNSET, sort_by:fastcore.xtras.Unset=UNSET, sort_order:fastcore.xtras.Unset=UNSET,
offset:fastcore.xtras.Unset=UNSET, limit:fastcore.xtras.Unset=UNSET, use_user:bool=False, nothread:bool=True
):Search this channel’s messages; a DM is searched as the user
await ch.search(has='file', limit=1)| ID | Author | Content | Date |
|---|---|---|---|
| 1549136217679986791 | share2self | Here is a file! | 2026-09-14 |
Attachment wraps a message attachment as a DiscordObject, with filename, size, content_type, and url as attributes. fetch downloads the file through the client’s httpx2 session.
Attachment
def Attachment(
data, client
):dict subclass that also provides access to keys as attrs, and has a pretty markdown repr
atts = msg.attachments
atts[Attachment(filename='README.md', size=4257, type=text/markdown; charset=utf-8)]
readme = (await atts[0].fetch()).decode()
print(readme[:16])# cordslite 🍺
Discord sets content_type only for files it recognises. The property falls back to a guess from the filename.
Attachment.save
async def save(
dest, # File path, or an existing directory to save under `filename`
):Download this attachment to dest and return the file
with tempfile.TemporaryDirectory() as td:
saved = await atts[0].save(td)
test_eq(saved.name, atts[0].filename)
test_eq(saved.read_text()[:16], readme[:16])
atts[0].content_type'text/markdown; charset=utf-8'
A DM is an ordinary channel in Discord’s API. create_dm posts to /users/@me/channels with a recipient_id and returns a standard Channel. send and messages work on it unchanged. In gateway events a DM message has no guild_id. A handler can tell DMs from guild messages by that missing field.
dm = await dc.create_dm('346450717025894400')
await dm.send('Hello from DMs!')Message(id=1549136531430580245, author='share2self', content='Hello from DMs!')Reading a person’s own DMs needs that person’s account token, not a bot’s. DiscordClient reads DISCORD_USER_TOKEN from the environment, or takes user_token. With no bot token it authenticates every request as the user. With both, a call passes use_user=True to send the user token for that request. Discord’s terms forbid automating a user account, so keep this to reading your own messages, and expect the token to change when the password does.
dms lists the account’s open DM channels from /users/@me/channels, which is empty for a bot. A DM (type 1) has no name and one recipient. A group DM (type 3) can have a name and several.
DiscordClient.dms
async def dms():The account’s open DM and group DM channels; needs a user token
dms = await dc.dms()
assert all(d.type in (ChannelType.DM, ChannelType.GROUP_DM) for d in dms)
dm = first(d for d in dms if d.type==ChannelType.DM)
test_eq(dm.url, f'{DISCORD_WEB}/@me/{dm.id}')
len(dms), len(dm.recipients)(205, 1)
dmsgs = await dm.messages(3, use_user=True)
assert all(m.channel_id==dm.id for m in dmsgs)
assert dmsgs[0].url.startswith(f'{DISCORD_WEB}/@me/{dm.id}/')
len(dmsgs)3
test_eq((await dc.channel(dm.id, use_user=True)).id, dm.id)search and search_all on a DM call /channels/{id}/messages/search, which only a user token can reach, so they send it without being asked. On a guild channel they delegate to the guild as before.
found = await dm.search(limit=2)
assert all(m.channel_id==dm.id for m in found)
len(found)2
A user is a global Discord account. A member is a user within one guild, with guild-specific data such as nickname, roles, and join date. nick or user['username'] shows the server nickname when set and the global username otherwise. The members endpoint needs the GUILD_MEMBERS privileged intent, enabled in the Developer Portal.
Members
def Members(
*args, **kwargs
):Built-in mutable sequence.
If no argument is given, the constructor creates a new empty list. The argument must be an iterable if specified.
Member
def Member(
data, client
):dict subclass that also provides access to keys as attrs, and has a pretty markdown repr
User
def User(
data, client
):dict subclass that also provides access to keys as attrs, and has a pretty markdown repr
display_name is what Discord shows for a member: the server nickname, else the global display name, else the username.
me is the bot’s own user. A bot needs it to recognise mentions of itself and its own messages.
me = await dc.me()
meUser(id=1274262434152185857, username='share2self')Discord omits the bot flag for people.
A message’s author and mentions are Users. referenced is the message a reply answers. It is None when the message is not a reply, and also when the answered message was deleted or Discord left it out of the payload.
m = await ch.send(f'<@{me.id}> are you there?')
test_eq([u.id for u in m.mentions], [me.id])
assert m.author.is_bot
r = await ch.send('Yes', reply_id=m.id)
test_eq(r.referenced.id, m.id)
assert msg.referenced is None
m.author, m.mentions, r.referenced(User(id=1274262434152185857, username='share2self'),
[User(id=1274262434152185857, username='share2self')],
Message(id=1549136538758021281, author='share2self', content='@share2self are you there?'))
mems = await gld.members(5)
test_eq(mems[0].display_name, mems[0].nick or mems[0].user.get('global_name') or mems[0].user['username'])
mems| ID | Name | Joined | Roles |
|---|---|---|---|
| 206121263213576192 | rensdimmendaal | 2026-04-14 | 1 |
| 281701548919095297 | frax___ | 2026-04-14 | 1 |
| 346450717025894400 | nate.dawgg | 2026-04-14 | 1 |
| 660097403046723594 | jeremyhoward | 2026-04-14 | 1 |
| 736643719675248691 | tommyc.xyz | 2026-04-14 | 1 |
Guild.tree
async def tree(
include_members:bool=True, member_limit:int=1000
):Printable map of the guild: categories and their channels with topics and IDs, then up to member_limit members if include_members
print(await gld.tree())AAI-test-dev [1493461895615873044]
|-- Uncategorized
|-- Text Channels
| |-- #general [1493461896139903028]
|-- Voice Channels
| |-- 🔊General [1493461896139903029]
|-- Members
| |-- Rens [206121263213576192]
| |-- curtis [281701548919095297]
| |-- natedog [346450717025894400]
| |-- Jeremy Howard [660097403046723594]
| |-- Tommy [736643719675248691]
| |-- share2self [1274262434152185857]
| |-- DBuddy [1361823507679543306]
Channel.search_all
async def search_all(
*, limit:int=500, delay:float=1.0, max_age_days:NoneType=None, show:bool=False
):Paginated search of this channel; a DM is searched as the user
Guild.search_all
async def search_all(
*, limit:int=500, delay:float=1.0, max_age_days:NoneType=None, show:bool=False
):Paginated search returning up to limit messages
len(await dm.search_all(limit=30))30
Channel.bulk_delete
async def bulk_delete(
message_ids
):Bulk delete messages (must be <14 days old)
Discord lists active threads per guild, not per channel. Channel.threads filters the guild’s list. Archived threads do not appear.
th = first(t for t in await ch.threads() if t.name=='cordslite thread test') or await msg.create_thread('cordslite thread test')
thChannel(id=1545381293401972796, name='cordslite thread test', type=11)ths = await ch.threads()
assert all(t.is_thread and t.parent_id==ch.id for t in ths)
assert any(t.id==th.id for t in ths)
test_eq((await th.parent).id, ch.id)
ths| ID | Name | Type |
|---|---|---|
| 1545484252366376962 | mirror test thread | 11 |
| 1545418481271439370 | mirror test thread | 11 |
| 1545415014444179547 | mirror test thread | 11 |
| 1545413411981234218 | mirror test thread | 11 |
| 1545413093553868861 | mirror test thread | 11 |
| 1545409591779655764 | mirror test thread | 11 |
| 1545409531515900064 | mirror test thread | 11 |
| 1545391950721450035 | mirror test thread | 11 |
| 1545391889807835258 | mirror test thread | 11 |
| 1545389625911017512 | mirror test thread | 11 |
| 1545389474366890084 | mirror test thread | 11 |
| 1545381293401972796 | cordslite thread test | 11 |
| 1545381229417861152 | cordslite thread test | 11 |
| 1545380975914254379 | cordslite thread test | 11 |
| 1545245745328824440 | mirror test thread | 11 |
| 1545245625539362918 | mirror test thread | 11 |
| 1545244224616800276 | mirror test thread | 11 |
| 1545243561233227847 | mirror test thread | 11 |
| 1545242655422812260 | mirror test thread | 11 |
Channel.search_and_delete_all
async def search_and_delete_all(
content, delay:int=2, show:bool=False, **kwargs
):Bulk delete recent msgs, individually delete older ones
A webhook lets an external service post to a channel without a bot token. Executing one needs only its id and token. Webhook wraps it. Channel.webhooks and Guild.webhooks list them. The webhook has edit, delete, and send. send passes ?wait=true. Discord then returns the created message. It accepts per-message username and avatar_url overrides.
Webhooks
def Webhooks(
*args, **kwargs
):Built-in mutable sequence.
If no argument is given, the constructor creates a new empty list. The argument must be an iterable if specified.
Webhook
def Webhook(
data, client
):dict subclass that also provides access to keys as attrs, and has a pretty markdown repr
Webhook.send
async def send(
content:str='', username:fastcore.xtras.Unset=UNSET, avatar_url:fastcore.xtras.Unset=UNSET
):Execute this webhook, optionally overriding the display username/avatar_url
Webhook.edit
async def edit(
name:fastcore.xtras.Unset=UNSET, channel_id:fastcore.xtras.Unset=UNSET
):Modify this webhook’s name or move it to channel_id