cordslite API

from fastcore.test import *
import tempfile

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:

  1. Create an application.
  2. Under “Bot”, add a bot and copy its token.
  3. Under “OAuth2”, generate an invite URL with the bot scope 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.


source

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.


source

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.


source

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}/channels
  • GET /channels/{id}/messages
  • POST /channels/{id}/messages

Guild, Channel, and Message subclass DiscordObject and add a __repr__.


source

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.


source

DiscordClient.guild

async def guild(
    guild_id
):
gid = '1493461895615873044'
gld = await dc.guild(gid)
gld
Guild(id=1493461895615873044, name='AAI-test-dev')

Every channel has a numeric type. ChannelType names them.


source

ChannelType

def ChannelType(
    *args, **kwds
):

Discord channel types


source

Guild.channels

async def channels(
    limit:NoneType=None
):

source

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.


source

Channel

def Channel(
    data, client
):

dict subclass that also provides access to keys as attrs, and has a pretty markdown repr


source

html_table

def html_table(
    items, hdrs, fn
):
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.


source

Channel.parent

async def parent():

source

Channel.is_thread

def is_thread():

source

Channel.is_text

def is_text():
[(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.


source

DiscordClient.guilds

async def guilds(
    limit:fastcore.xtras.Unset=UNSET
):

List the guilds the bot is a member of


source

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

source

DiscordClient.channel

async def channel(
    channel_id, use_user:bool=False
):
chid = '1493461896139903028'

ch = await dc.channel(chid)
ch
Channel(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.


source

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.


source

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.


source

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

source

Message.around

async def around(
    limit:int=11
):

Fetch messages around this message in the same channel/thread.


source

Message.after

async def after(
    limit:int=5
):

Fetch messages after this message in the same channel/thread.


source

Message.before

async def before(
    limit:int=5
):

Fetch messages before this message in the same channel/thread.


source

Message.url

def url():

source

Channel.url

def url():

source

Guild.url

def url():
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.


source

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.


source

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.


source

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.


source

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!')
msg
Message(id=1549136518638075925, author='share2self', content='Hi, from Solveit!')
reply_msg = await ch.send("I'm replying to myself 🤓", reply_id=msg.id)
reply_msg
Message(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.


source

Channel.typing

async def typing():

Show the bot as typing in this channel for a few seconds

await ch.typing()
await msg.channel
Channel(id=1493461896139903028, name='general', type=0)
msg = await ch.send('Here is a file!', files=['../README.md'])
msg
Message(id=1549136523583295509, author='share2self', content='Here is a file!')

source

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.


source

Message.attachments

def attachments():

source

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.


source

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


source

Attachment.content_type

def content_type():
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.


source

DiscordClient.create_dm

async def create_dm(
    user_id
):
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.


source

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.


source

Guild.members

async def members(
    limit:int=100
):

source

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.


source

Member

def Member(
    data, client
):

dict subclass that also provides access to keys as attrs, and has a pretty markdown repr


source

User

def User(
    data, client
):

dict subclass that also provides access to keys as attrs, and has a pretty markdown repr


source

Member.display_name

def display_name():

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.


source

DiscordClient.me

async def me():

The user this client is authenticated as

me = await dc.me()
me
User(id=1274262434152185857, username='share2self')

Discord omits the bot flag for people.


source

User.is_bot

def is_bot():

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.


source

Message.referenced

def referenced():

source

Message.mentions

def mentions():

source

Message.author

def author():
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

source

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


source

lbl

def lbl(
    ch
):
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]

source

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


source

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

source

Channel.bulk_delete

async def bulk_delete(
    message_ids
):

Bulk delete messages (must be <14 days old)


source

Channel.delete_message

async def delete_message(
    message_id
):

Delete a message by ID


source

Message.delete

async def delete():

Delete this message


source

DiscordClient.thread

async def thread(
    thread_id
):

Fetch a thread (which is a Channel)


source

Message.create_thread

async def create_thread(
    name
):

Create a thread from this message

Discord lists active threads per guild, not per channel. Channel.threads filters the guild’s list. Archived threads do not appear.


source

Channel.threads

async def threads():

This channel’s active threads


source

Guild.threads

async def threads():

The guild’s active threads

th = first(t for t in await ch.threads() if t.name=='cordslite thread test') or await msg.create_thread('cordslite thread test')
th
Channel(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

source

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.


source

DiscordClient.webhook

async def webhook(
    webhook_id
):

Fetch a webhook by ID


source

Guild.webhooks

async def webhooks():

List all webhooks in this guild


source

Channel.webhooks

async def webhooks():

List this channel’s webhooks


source

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.


source

Webhook

def Webhook(
    data, client
):

dict subclass that also provides access to keys as attrs, and has a pretty markdown repr


source

Channel.create_webhook

async def create_webhook(
    name
):

Add webhook name to Channel


source

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


source

Webhook.delete

async def delete():

Delete this webhook


source

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