cordslite API

from IPython.display import Audio

Discord has three main APIs: - REST API - for actions (send message, get channels, fetch history). Request/response style. - Gateway API - for real-time events via WebSocket (new messages, reactions, user joins). - Voice API - for real-time streaming of audio via UDP

We start with the REST API. To use it, you need a bot token from the Discord Developer Portal: 1. Create an application 2. Go to “Bot” section and create a bot 3. Copy the token (keep it secret!) 4. Under “OAuth2 → URL Generator”, select bot scope and choose permissions (e.g., Send Messages, Read Message History) 5. Use the generated URL to invite the bot to your server

The DiscordClient wraps httpx.Client with Discord’s base URL (https://discord.com/api/v10) and auth headers pre-configured.

Failed to import opuslib-next

source

DiscordClient


def DiscordClient(
    token:NoneType=None, user_token:NoneType=None, name:str='cordslite', ver:str='0.1'
):

Initialize self. See help(type(self)) for accurate signature.

The token defaults to the DISCORD_BOT_TOKEN environment variable—no hardcoding secrets!

dc = DiscordClient()

REST endpoints

Every REST call goes through _req, which handles two concerns automatically: - Rate limiting — if Discord returns 429 Too Many Requests, we wait the Retry-After duration and retry. - Error handling — non-2xx responses are raised as DiscordError with Discord’s error code, message, and HTTP status, so callers don’t need to check responses manually.


source

DiscordError


def DiscordError(
    code, msg, status
):

Common base class for all non-exit exceptions.

Discord’s REST endpoints return JSON with many fields. Rather than defining properties for every field, we use a flexible base class pattern:

DiscordObject provides __getitem__ and __getattr__ so you can access data as obj.name or obj['name']. The __dir__ method enables autocomplete in notebooks and solveit dialogs—just type obj. and see all available fields!

This means when Discord adds new fields to their API, our code automatically supports them without changes.


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 → Channels → Messages. A Guild (server) contains channels, and channels contain messages. Each has its own REST endpoints: - GET /guilds/{id} - fetch guild info - GET /guilds/{id}/channels - list channels - GET /channels/{id}/messages - fetch messages - POST /channels/{id}/messages - send a message

We build wrapper classes for each, inheriting from DiscordObject and just adding a nice __repr__.


source

Guild


def Guild(
    data, client
):

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

We use @patch from fastcore to add methods incrementally—great for interactive development where you want to test each piece as you build it. This guild method fetches guild data and wraps it in our Guild class.


source

DiscordClient.guild


async def guild(
    guild_id
):

Call self as a function.

# gid = '1493461895615873044'
gid = '1327046393453613076'
gld = await dc.guild(gid); gld
Guild(id=1327046393453613076, name="natedog's server")

Channels have a type field: 0=text, 2=voice, 4=category. The Channels class inherits from list and adds _repr_html_ for nice table display in notebooks/solveit. This pattern—a wrapper class plus a collection class with HTML repr—makes exploring the API much more pleasant.


source

html_table


def html_table(
    items, hdrs, fn
):

Call self as a function.


source

Channels


def Channels(
    args:VAR_POSITIONAL, kwargs:VAR_KEYWORD
):

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

Guild.channels


async def channels(
    limit:NoneType=None
):

Call self as a function.

chs = await gld.channels(5)
chs
ID Name Type
1327046393453613077 Text Channels 4
1327046393453613078 Voice Channels 4
1327046393453613079 general 0
1327046393453613080 General 2
1327954661960978512 private 0

source

DiscordClient.channel


async def channel(
    channel_id
):

Call self as a function.

chid = '1327046393453613079' # '1493461896139903028'
ch = await dc.channel(chid)
ch
Channel(id=1327046393453613079, name='general', type=0)

Messages are the core of most bot functionality. Note that Discord returns messages in reverse chronological order (newest first), so we reverse() the data to get chronological order. The table shows a preview of content, author, and timestamp.

Note: To read message content, your bot needs the MESSAGE_CONTENT privileged intent enabled in the Developer Portal! Without it, the content field will be empty for messages not sent by your bot or mentioning it.


source

Message


def Message(
    data, dobj
):

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


source

Messages


def Messages(
    args:VAR_POSITIONAL, kwargs:VAR_KEYWORD
):

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.messages


async def messages(
    limit:int=50
):

Call self as a function.

msgs = await ch.messages(5); msgs
ID Author Content Date
1494381457928618014 DBuddy I'm replying to myself 🤓 2026-04-16
1494381464177999893 DBuddy Here is a file! 2026-04-16
1494381474240270511 DBuddy Test our event listener! Otters are awesome 🦦 2026-04-16
1494381547586064515 DBuddy Houston, do we have a problem? 2026-04-16
1494381604842504372 DBuddy Did we re-identify? 2026-04-16

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:NoneType=None, author_id:NoneType=None, channel_id:NoneType=None, mentions:NoneType=None,
    has:NoneType=None, before:NoneType=None, after:NoneType=None, pinned:NoneType=None, sort_by:NoneType=None,
    sort_order:NoneType=None, offset:NoneType=None, limit:NoneType=None, use_user:bool=False
):

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

await gld.search(after='2026-02-16', limit=5)
ID Author Content Date
1494381604842504372 DBuddy Did we re-identify? 2026-04-16
1494381547586064515 DBuddy Houston, do we have a problem? 2026-04-16
1494381474240270511 DBuddy Test our event listener! Otters are awesome 🦦 2026-04-16
1494381464177999893 DBuddy Here is a file! 2026-04-16
1494381457928618014 DBuddy I'm replying to myself 🤓 2026-04-16

Sometimes you need to search by name rather than snowflake ID. find_member searches the guild’s members by username, nickname, or display name using Discord’s member search endpoint, and returns the first match’s user ID. This makes it easy to chain into search.


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('jeremyhoward')
assert uid
await gld.search(author_id=uid, limit=5)

The members property fetches all guild members and returns their display names, preferring server nickname over global display name over username. Note: this requires the Server Members Intent enabled in the Developer Portal (Bot → Privileged Gateway Intents).


source

Guild.members


async def members(
    
):

List all guild members’ display names (nick > global_name > username)

(await gld.members)[:5]
['nathan', 'SearchBuddy', 'DBuddy', 'Dizcord Util Bot', 'Search Agent']

Sending messages is a POST request with JSON body. Pass reply_id to thread a reply under an existing message. For file attachments, we switch to multipart/form-data—Discord expects a payload_json field with the message JSON, plus files[n] fields for each 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=1494383523015164087, author='DBuddy', content='Hi, from Solveit!')
reply_msg = await ch.send("I'm replying to myself 🤓", reply_id=msg.id); reply_msg
Message(id=1494383523946303661, author='DBuddy', content="I'm replying to myself 🤓")
await msg.channel
Channel(id=1327046393453613079, name='general', type=0)
msg = await ch.send('Here is a file!', files=['../README.md']); msg
Message(id=1494383525539872970, author='DBuddy', content='Here is a file!')

source

Channel.search


async def search(
    content:NoneType=None, author_id:NoneType=None, mentions:NoneType=None, has:NoneType=None, before:NoneType=None,
    after:NoneType=None, pinned:NoneType=None, sort_by:NoneType=None, sort_order:NoneType=None, offset:NoneType=None,
    limit:NoneType=None, use_user:bool=False
):

Call self as a function.

await ch.search(has='file', limit=1)
ID Author Content Date
1494381464177999893 DBuddy Here is a file! 2026-04-16

Discord messages can have file attachments. The Attachment class wraps them as DiscordObjects—giving you attribute access to filename, size, content_type, url, etc. The fetch method downloads the file content using the existing httpx client.


source

Message.attachments


def attachments(
    
):

Call self as a function.


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=28163, type=text/markdown; charset=utf-8)]
readme = (await atts[0].fetch()).decode()
print(readme[:16])
# cordslite 🍺


DMs (Direct Messages) are just regular channels in Discord’s API — no special handling needed! To start a DM conversation, POST to /users/@me/channels with a recipient_id. Discord returns a standard channel object, so send() and messages() work exactly as they do for guild channels.

To detect DMs in the gateway, check for the absence of guild_id — DM messages don’t belong to any guild, so this field is missing or None. This makes it easy to route DM vs guild messages in your bot’s handler.


source

DiscordClient.create_dm


async def create_dm(
    user_id
):

Call self as a function.

# # Commented out so we don't spam Nate
# dm = await dc.create_dm('346450717025894400')  # nathan's user ID
# await dm.send('Hello from DMs!')

Members vs Users: A User is a global Discord account. A Member is a user within a specific guild—it has guild-specific data like nickname, roles, and join date. The nick or user['username'] pattern shows the server nickname if set, otherwise falls back to the global username.

Note: The members endpoint requires the GUILD_MEMBERS privileged intent enabled in your bot settings on the Developer Portal!


source

Guild.members


async def members(
    limit:int=100
):

Call self as a function.


source

Members


def Members(
    args:VAR_POSITIONAL, kwargs:VAR_KEYWORD
):

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

mems = await gld.members(5); mems
ID Name Joined Roles
346450717025894400 nathan 2025-01-09 0
1327047896436178954 SearchBuddy 2025-01-09 1
1361823507679543306 DBuddy 2025-04-15 1
1448038710229733398 Dizcord Util Bot 2025-12-09 1
1467222191182712986 Search Agent 2026-01-31 1

source

Channel.search_all


async def search_all(
    limit:int=500, delay:float=1.0, max_age_days:NoneType=None, show:bool=False
):

Call self as a function.


source

Guild.search_all


async def search_all(
    limit:int=500, delay:float=1.0, max_age_days:NoneType=None, show:bool=False, kwargs:VAR_KEYWORD
):

Paginated search returning up to limit messages

# r = await ch.search_all()
# len(r)

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


source

Channel.search_and_delete_all


async def search_and_delete_all(
    content, delay:int=2, show:bool=False, kwargs:VAR_KEYWORD
):

Bulk delete recent msgs, individually delete older ones

Gateway API

The Gateway is Discord’s WebSocket connection for real-time events. Unlike the REST API (request/response), the Gateway pushes events to you as they happen—new messages, reactions, user joins, etc.

The connection lifecycle is: 1. Fetch WSS URL from /gateway/bot 2. Connect to WebSocket 3. Receive Hello event with heartbeat interval 4. Start heartbeat loop to keep connection alive 5. Send Identify with your token and intents 6. Receive Ready event—you’re connected!

Intents tell Discord which events you want. They’re bitwise flags you OR together: - 1 << 0 = GUILDS (guild/channel events) - 1 << 9 = GUILD_MESSAGES (message events) - 1 << 15 = MESSAGE_CONTENT (privileged—see message content)


source

GatewayClient


def GatewayClient(
    intents, client, token:NoneType=None
):

Initialize self. See help(type(self)) for accurate signature.

# For now, let's use basic intents for guilds and messages
intents = (1 << 0) | (1 << 9) | (1 << 15)  # GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT

gc = GatewayClient(intents, dc); gc
GatewayClient(self.intents=33281, self.url='wss://gateway.discord.gg?v=10&encoding=json')

Op is a thin helper for constructing Gateway opcodes. Discord’s Gateway expects JSON payloads with an op field (operation code) and d field (payload data). Rather than hand-building dicts each time, Op.identify() and Op.heartbeat() return properly structured payloads ready to send over the WebSocket.


source

Op


def Op(
    args:VAR_POSITIONAL, kwargs:VAR_KEYWORD
):

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

The Gateway sends events as JSON with four fields: - op — operation code: 0 = dispatch (real events), 1 = heartbeat request, 10 = hello, 11 = heartbeat ACK - t — event type name (only for dispatches): MESSAGE_CREATE, GUILD_CREATE, etc. - s — sequence number (only for dispatches): increments per event, needed for heartbeats and resuming - d — the payload data

We track the latest s value so heartbeats can tell Discord “I’ve received everything up to here.” The Event class wraps raw JSON and auto-converts known dispatch types (like MESSAGE_CREATE) into their corresponding wrapper classes (Message, Channel, etc.) via evt_typs.


source

Event


def Event(
    data, client
):

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


ClientConnection.send


async def send(
    msg, kw:VAR_KEYWORD
):

Call self as a function.

recv_evt is the low-level building block for receiving events. It reads one raw WebSocket message, wraps it as an Event (auto-converting known dispatch types), and updates the sequence counter. You can use it directly to pull events one at a time — useful for debugging or understanding what Discord is sending.


source

GatewayClient.recv_evt


async def recv_evt(
    
):

Call self as a function.

Rather than special-casing the READY event inside _listen, we register it as a regular dispatch handler via gc.on(). When Discord confirms a successful Identify, on_rdy caches session_id, user_id, and resume_gateway_url — the three values needed to Resume after a disconnect. Note the query params appended to resume_gateway_url — Discord returns it bare, but the docs require the same version and encoding as the initial connection.


source

GatewayClient.on


def on(
    event_type, handler
):

Call self as a function.

The gateway runs on two cooperating loops. _hb sends heartbeats at the interval Discord specifies and watches for ACK responses — if one is missed, the connection is zombied and gets closed with code 4000 (which preserves the session for Resuming). _listen reads events and dispatches them by opcode. On disconnect (exception from recv_evt), it calls _reconnect to swap in a fresh WebSocket. The new connection triggers Hello (op 10), which restarts the heartbeat and sends either Identify (first connect) or Resume (reconnect) depending on whether a session already exists. Op 7 (Reconnect) follows the same path — close and reopen.

All reconnection funnels through _reconnect, which closes with code 4000 (keeping the session valid) and opens a new socket to resume_gateway_url. The listener’s op 10 handler then takes over automatically.

start opens the initial WebSocket and spawns the listener — nothing else. The listener handles Hello, kicks off the heartbeat, and sends Identify, so the full connection lifecycle is driven by events rather than imperative setup.


source

GatewayClient.start


async def start(
    debug:bool=False
):

Call self as a function.

await gc.start(debug=True)
DEBUG: Received Opcode: 10
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 11
sent 4000 (private use); then received 4000 (private use)
DEBUG: Received Opcode: 10
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 10
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DBuddy: Houston, do we have a problem?
sent 1000 (OK); then received 1000 (OK)
DEBUG: Received Opcode: 10
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DBuddy: Did we re-identify?
DEBUG: Received Opcode: 11

Let’s now test that our gateway is getting events. Here is a simple handlers for new messages.

async def on_msg(msg):
    if not msg.author.get('bot'): return
    print(f"{msg.author['username']}: {msg.content}")

gc.on('MESSAGE_CREATE', on_msg)

Now, we can send a message and it should be printed out.

await ch.send('Test our event listener! Otters are awesome 🦦')
Message(id=1494383536193536111, author='DBuddy', content='Test our event listener! Otter…')

Let’s trigger and reconnection that resumes and one that doesn’t and see if everything works correctly and we still end up with our printed message.

await gc._reconnect(resume=True)
await asyncio.sleep(2)
await ch.send('Houston, do we have a problem?')
Message(id=1494383629688901683, author='DBuddy', content='Houston, do we have a problem?')
await gc._reconnect(resume=False)
await asyncio.sleep(2)
await ch.send('Did we re-identify?')
Message(id=1494383657929019646, author='DBuddy', content='Did we re-identify?')

stop is a full shutdown — cancels both background tasks and closes the WebSocket with the default code (1000), which tells Discord to invalidate the session. After this, a fresh start() would need to Identify from scratch.


source

GatewayClient.stop


async def stop(
    
):

Call self as a function.

await gc.stop()

Voice API

await gc.start()
vch = chs[3]; vch
Connected! Session: 071532487214aa5877a8dcc277014457, heartbeat: 41250ms
Gateway started!
Channel(id=1327046393453613080, name='General', type=2)

Voice requires three simultaneous connections:

  1. Main Gateway WebSocket (gc) — to request joining a voice channel and receive voice server info
  2. Voice Gateway WebSocket — a separate WebSocket to a dedicated voice server for session coordination
  3. Voice UDP — a UDP (datagram) connection for actual audio data. UDP is used over TCP because real-time audio needs low latency more than guaranteed delivery

VoiceClient manages the voice gateway WebSocket and UDP connections for a single voice channel, coordinated through the main GatewayClient.


source

VoiceClient


def VoiceClient(
    gateway, channel
):

Initialize self. See help(type(self)) for accurate signature.

vc = VoiceClient(gc, vch); vc
VoiceClient(self.ch=Channel(id=1327046393453613080, name='General', type=2), self.running=False)

Voice requires additional Op opcodes beyond the main gateway’s identify/heartbeat. These handle the voice-specific protocol: requesting to join a channel (voice_state), authenticating with the voice server (voice_identify), negotiating encryption (select_protocol), signaling audio intent (speaking), and keeping the voice connection alive (voice_heartbeat).

The voice heartbeat uses a different format from the main gateway — v8 requires seq_ack tracking the last received sequence number. It must start immediately after receiving Hello, before UDP setup, or the voice WebSocket will time out (close code 4006).


source

Op.voice_heartbeat


def voice_heartbeat(
    seq_ack:int=-1
):

Call self as a function.


source

Op.speaking


def speaking(
    ssrc, speaking:int=0
):

Call self as a function.


source

Op.select_protocol


def select_protocol(
    ip, port, mode:str='aead_xchacha20_poly1305_rtpsize'
):

Call self as a function.


source

Op.voice_identify


def voice_identify(
    server_id, user_id, session_id, token
):

Call self as a function.


source

Op.voice_state


def voice_state(
    guild_id, channel_id
):

Call self as a function.

_connect handles the voice WebSocket handshake: request to join via the main gateway, wait for Discord to assign a voice server, connect to it, authenticate, and start the voice heartbeat. The heartbeat must begin immediately after Hello — before UDP setup — or the voice WebSocket times out (close code 4006).

await vc._connect(); vc

IP Discovery: Discord needs your external IP/port (what the internet sees after NAT), not your local one. We send a 74-byte UDP request containing our SSRC, and Discord responds with our external address filled in:

Field Size Description
Type 2 bytes 1=request, 2=response
Length 2 bytes 70 (size of remaining fields)
SSRC 4 bytes Our audio stream identifier
Address 64 bytes Blank in request; our external IP in response
Port 2 bytes Blank in request; our external port in response

Note: Discord requires a Speaking event (even with speaking=0) before it will send audio packets to you.


source

get_ip


async def get_ip(
    trans, proto, ssrc
):

Call self as a function.

_udp sets up the audio transport: open UDP socket, discover our external IP, tell Discord which encryption mode to use (select_protocol), receive the secret key, and signal that we’re ready to speak.


source

VoiceUDP


def VoiceUDP(
    
):

Interface for datagram protocol.

await vc._udp()
# vc.secret

join ties the three phases together into a single call: connect to voice server, set up UDP, ready to receive audio.


source

VoiceClient.join


async def join(
    
):

Call self as a function.

await vc.join()
Voice ready!

Audio arrives as UDP packets using the RTP (Real-time Transport Protocol) format. Each packet contains: - RTP header (12+ bytes) — version, sequence number, timestamp, SSRC (identifies which user is speaking) - Encrypted payload — the Opus-encoded audio, encrypted with the secret_key - Nonce (4 bytes at end) — used for decryption

Decryption with aead_xchacha20_poly1305_rtpsize: - The cipher expects a 24-byte nonce, but only 4 bytes are transmitted — we pad with 20 zero bytes - The unencrypted RTP header is used as AAD (Additional Authenticated Data) — it’s verified but not encrypted - For rtpsize mode, the AAD includes the 12-byte base header, any CSRC entries (4 bytes each, usually 0), and only the 4-byte extension preamble — the extension data is part of the encrypted payload - After decryption, we skip past the extension data (its length is in bytes 14-15 of the original packet, multiplied by 4) to reach the raw Opus audio

Packets with byte[1] == 0x78 are RTP voice data. Other packets (like 0xC9) are RTCP control packets used for connection quality reporting — we skip those.


source

decrypt_pkt


def decrypt_pkt(
    pkt, secret
):

Call self as a function.

Recording writes each speaker to a separate file (recording_<ssrc>.mp3) rather than mixing in real-time. This keeps the code simple and gives us per-speaker files — ideal for transcription. Each speaker gets their own Opus decoder because Opus is stateful — feeding multiple speakers through one decoder corrupts its internal state and produces garbled audio.

Silence padding ensures all files stay time-aligned: at the start (padding from _rec_start to first packet), and during gaps (using RTP timestamps, which Discord increments continuously even during silence). Without this, files would be compressed in time and impossible to merge or correlate.


source

silence


def silence(
    n_smpls:int, # 2 bytes per sample (s16le)
):

Call self as a function.

start_recording drains any previously queued packets so recordings start clean. stop_recording calls communicate() on each ffmpeg process to flush buffered audio and cleanly close the output files — without this, recordings may be truncated or corrupted.


source

VoiceClient.stop_recording


def stop_recording(
    
):

Call self as a function.


source

VoiceClient.start_recording


def start_recording(
    path:str='recording.mp3'
):

Call self as a function.

pth = '/tmp/recording.mp3'
vc.start_recording(path=pth)
await asyncio.sleep(10)
rpths = vc.stop_recording(); rpths
# Audio(rpths[0])

To leave a voice channel cleanly: 1. Stop the heartbeat loop 2. Close the Voice WebSocket 3. Close the UDP transport 4. Send a Voice State Update with channel_id=None on the main gateway — this tells Discord to remove the bot from the voice channel. Without this, the bot appears to stay in the channel until it times out.


source

VoiceClient.leave


async def leave(
    
):

Call self as a function.

await vc.leave()
await gc.stop()
Gateway stopped!

Bots

Bot ties together DiscordClient (REST) and GatewayClient (events) into a single object with a decorator-based command router. Commands are registered with @bot.cmd — the function name becomes the command name, prefixed with ! in Discord. So def echo responds to !echo. Every command handler takes two arguments: the Message object and a string of everything the user typed after the command name (empty string if nothing).

The _on_msg handler ignores messages from the bot itself to prevent infinite loops — a common gotcha with Discord bots. It splits the message into command name + args, so !echo hello world passes "hello world" as the args string to the handler.


source

Bot


def Bot(
    intents, kw:VAR_KEYWORD
):

Discord bot with command routing

bot = Bot(intents)
await bot.start()
Connected! Session: aebd851f6f7e226c5f982f03cf7d27ef, heartbeat: 41250ms
Gateway started!

Commands can be registered at any time — even after bot.start(). This works because @bot.cmd just adds the function to a dict; the message handler looks up commands dynamically on each message.

@bot.cmd
async def echo(msg, args): await (await msg.channel()).send(f'You said: {args}')
bot
Bot(cmds=['echo'])

Errors in command handlers are caught and stored in bot.errors — useful for debugging in dynamic environments like solveit where you can inspect the list after the fact. For real-time handling (e.g. notifying the user in Discord), register a handler with @bot.on_error. Both mechanisms work simultaneously.


source

Bot.on_error


def on_error(
    f
):

Call self as a function.

@bot.on_error
async def handle_err(msg, e): print('error')
@bot.cmd
def err(msg): raise Exception('test')
bot.errors
[]

Voice integration reuses the existing VoiceClientBot just provides convenience methods to manage the lifecycle. The bot can only be in one voice channel at a time.


source

Bot.leave_voice


async def leave_voice(
    
):

Leave the current voice channel


source

Bot.join_voice


async def join_voice(
    channel
):

Join a voice channel and return VoiceClient

vc = await bot.join_voice(vch); vc
Voice ready!
VoiceClient(self.ch=Channel(id=1327046393453613080, name='General', type=2), self.running=True)
vc.start_recording()
'recording.mp3'
pth = vc.stop_recording()
await bot.leave_voice()
# Audio(pth)