from IPython.display import Audiocordslite API
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
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.
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.
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__.
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.
DiscordClient.guild
async def guild(
guild_id
):
Call self as a function.
# gid = '1493461895615873044'
gid = '1327046393453613076'
gld = await dc.guild(gid); gldGuild(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.
html_table
def html_table(
items, hdrs, fn
):
Call self as a function.
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.
Channel
def Channel(
data, client
):
dict subclass that also provides access to keys as attrs, and has a pretty markdown repr
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 |
DiscordClient.channel
async def channel(
channel_id
):
Call self as a function.
chid = '1327046393453613079' # '1493461896139903028'ch = await dc.channel(chid)
chChannel(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.
Message
def Message(
data, dobj
):
dict subclass that also provides access to keys as attrs, and has a pretty markdown repr
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.
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.
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.
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.
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).
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.
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=1494383523015164087, author='DBuddy', content='Hi, from Solveit!')reply_msg = await ch.send("I'm replying to myself 🤓", reply_id=msg.id); reply_msgMessage(id=1494383523946303661, author='DBuddy', content="I'm replying to myself 🤓")await msg.channelChannel(id=1327046393453613079, name='general', type=0)msg = await ch.send('Here is a file!', files=['../README.md']); msgMessage(id=1494383525539872970, author='DBuddy', content='Here is a file!')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.
Message.attachments
def attachments(
):
Call self as a function.
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.
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!
Guild.members
async def members(
limit:int=100
):
Call self as a function.
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.
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
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 |
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.
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)Channel.bulk_delete
async def bulk_delete(
message_ids
):
Bulk delete messages (must be <14 days old)
Channel.delete_message
async def delete_message(
message_id
):
Delete a message by ID
Message.delete
async def delete(
):
Delete this message
DiscordClient.thread
async def thread(
thread_id
):
Fetch a thread (which is a Channel)
Message.create_thread
async def create_thread(
name
):
Create a thread from this message
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)
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); gcGatewayClient(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.
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.
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.
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.
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.
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.
GatewayClient.stop
async def stop(
):
Call self as a function.
await gc.stop()Voice API
await gc.start()
vch = chs[3]; vchConnected! Session: 071532487214aa5877a8dcc277014457, heartbeat: 41250ms
Gateway started!
Channel(id=1327046393453613080, name='General', type=2)
Voice requires three simultaneous connections:
- Main Gateway WebSocket (
gc) — to request joining a voice channel and receive voice server info - Voice Gateway WebSocket — a separate WebSocket to a dedicated voice server for session coordination
- 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.
VoiceClient
def VoiceClient(
gateway, channel
):
Initialize self. See help(type(self)) for accurate signature.
vc = VoiceClient(gc, vch); vcVoiceClient(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).
Op.voice_heartbeat
def voice_heartbeat(
seq_ack:int=-1
):
Call self as a function.
Op.speaking
def speaking(
ssrc, speaking:int=0
):
Call self as a function.
Op.select_protocol
def select_protocol(
ip, port, mode:str='aead_xchacha20_poly1305_rtpsize'
):
Call self as a function.
Op.voice_identify
def voice_identify(
server_id, user_id, session_id, token
):
Call self as a function.
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(); vcIP 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.
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.
VoiceUDP
def VoiceUDP(
):
Interface for datagram protocol.
await vc._udp()# vc.secretjoin ties the three phases together into a single call: connect to voice server, set up UDP, ready to receive audio.
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.
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.
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.
VoiceClient.stop_recording
def stop_recording(
):
Call self as a function.
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.
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.
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}')botBot(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.
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 VoiceClient — Bot just provides convenience methods to manage the lifecycle. The bot can only be in one voice channel at a time.
Bot.leave_voice
async def leave_voice(
):
Leave the current voice channel
Bot.join_voice
async def join_voice(
channel
):
Join a voice channel and return VoiceClient
vc = await bot.join_voice(vch); vcVoice 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)