Skip to content
deeeeonePublic

About

Set a Discord custom rich presence using Python

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

customrpc

Set a Discord custom rich presence from Python. You give it a token, it keeps a gateway connection alive and pushes whatever presence you want.

Heads up: driving a user account over the gateway is self-botting, which Discord's Terms of Service prohibit and which can get an account actioned. The library also works with a bot token, which is the supported route.

Install

pip3 install -r requirements.txt

Only dependency is websockets>=12. Python 3.9+.

Quick start

from customrpc import RichPresence

with RichPresence("YOUR_TOKEN") as rpc:
    print("Logged in as", rpc.username)
    rpc.set(
        name="Visual Studio Code",
        details="Editing gateway.py",
        state="Workspace: rpc-me",
    )
    rpc.run_forever()   # Ctrl-C to stop; presence is cleared on exit

Non-blocking version:

rpc = RichPresence(token).start()      # returns immediately
rpc.wait_until_ready()
rpc.set(name="My App")
...
rpc.close()

Command line

export DISCORD_TOKEN='your token'
python3 -m customrpc --name "My App" --details "Line one" --state "Line two" --elapsed
python3 -m customrpc --name "A Song" --type listening --details Song --state Artist --status dnd

--help lists every flag. With no --token and no $DISCORD_TOKEN it prompts (hidden input), which keeps the token out of your shell history. --json file loads the presence fields from a JSON file, and --duration N exits after N seconds instead of running until Ctrl-C.

API

RichPresence(token, **options)

option default meaning
status "online" online, idle, dnd, invisible, offline
auto_reconnect True reconnect on drops, with backoff
max_reconnect_attempts None give up after N tries (None = never)
reconnect_base_delay 5.0 first backoff step, doubling to a 16× cap
clear_on_close True wipe the presence when disconnecting
auto_external_assets True proxy image URLs through Discord (see below)
on_ready / on_state / on_error None callbacks
gateway_url / api_base Discord override for testing

Methods: connect(timeout), start(), wait_until_ready(), set(**fields), set_presence(presence), update(**fields), clear(), set_status(status), run_forever(), close(). Properties: connected, user, username, latency_ms, presence.

update() changes only the fields you name and keeps the rest:

rpc.set(name="Game", details="Level 1", large_image="icon")
rpc.update(details="Level 2")     # name and image are preserved

Presence

Every field, all optional but name:

from customrpc import Presence, ActivityType, Status

presence = Presence(
    name="rpc-me",                       # the bold first line
    details="Testing customrpc",         # second line
    state="Round 1 of 10",               # third line
    activity_type=ActivityType.PLAYING,  # PLAYING/STREAMING/LISTENING/WATCHING/COMPETING
    status=Status.DND,

    application_id="123...",             # required to use image URLs
    large_image="python", large_text="Large tooltip",
    small_image="discord", small_text="Small tooltip",

    start=Presence.elapsed_since_now(),  # "01:23 elapsed"
    end=Presence.remaining(300),         # "05:00 left"

    party_size=1, party_max=10,          # "(1 of 10)"
    party_id="lobby-7",
    join_secret="...",

    buttons=[("Source", "https://github.com/..."), ("Docs", "https://...")],
    streaming_url="https://twitch.tv/name",   # for ActivityType.STREAMING
)
rpc.set_presence(presence)

Timestamps accept a datetime, unix seconds, or unix milliseconds. Presence.copy(**changes) returns a modified copy; Presence.from_dict() builds one from a dict and rejects unknown keys.

Everything is validated before anything is sent, so a bad presence raises PresenceValidationError rather than being silently dropped by Discord: empty name, activity type 4 (custom status, not settable over the gateway), more than two buttons, non-http button URLs, labels over 32 characters, half-specified parties, party_size > party_max, start after end, non-Twitch/YouTube streaming URLs.

Images

Discord does not render arbitrary image URLs. It renders asset keys uploaded to an application, or proxied mp:external/... keys. If you pass a http(s) URL as large_image/small_image and set application_id, this library exchanges it for a proxied key automatically (cached, so repeat updates are free). Create an application ID at https://discord.com/developers/applications — New Application, copy the ID; no bot user and no code needed.

The app does not have to be yours for URL proxying — the request is authenticated with your token and the app ID only namespaces the proxy entry, so any visible ID works. Two caveats: that endpoint is undocumented client-only API, so the behaviour isn't a contract, and borrowing an app you don't control means your images break if it disappears.

Ownership does matter for uploaded asset keys (large_image="my_cover"). Those live under an application's Rich Presence → Art Assets, you can only upload to an app you own, and the activity's application_id has to be that same app or Discord has nothing to resolve the key against.

If you set a URL with no application_id, you get a warning and the URL is sent unchanged, which Discord will ignore.

Buttons

Two shapes exist in the wild. button_style="objects" (default, what the reference Android app sends) emits buttons: [{label, url}]; button_style="metadata" emits buttons: ["label"] plus metadata.button_urls, which is what the official desktop client sends. If your buttons don't appear, try the other one — and note that Discord hides rich-presence buttons on your own profile, so check from another account.

Errors

CustomRPCError is the base. AuthenticationError (bad/expired token, close code 4004, or op 9 before READY — never retried), GatewayError, NotConnectedError, PresenceValidationError, ConnectionTimeout.

Low level

DiscordGateway is the raw connection if you want to drive op codes yourself: connect(wait=), send(payload), send_presence(payload), close(), plus latency, user, session_id, last_error.

Testing

Offline suite — no token, no network, no account

./run_tests.sh

or python3 -m unittest discover -s tests -t . -v. They are plain unittest.TestCases, so pytest collects them as-is if you prefer that runner. 88 tests, about 12 seconds:

file covers
tests/test_presence.py payload construction and every validation rule
tests/test_gateway.py handshake, heartbeat/ACK, op 7 reconnect, op 9, close 4004, dead heartbeats, unreachable server
tests/test_client.py RichPresence lifecycle, set/update/clear/status, auth failures
tests/test_assets.py image-URL proxying, caching, API errors
tests/test_cli.py argument parsing, token resolution, a full CLI run

tests/fake_gateway.py is a local websocket server that speaks the v10 gateway protocol, so the connection logic is exercised for real (handshake, heartbeats, reconnects) without touching Discord. Its mode switch simulates the failure paths: bad_token, invalid_session, reconnect_once, no_ack, close_4004.

Live test — needs your real token

export DISCORD_TOKEN='your token'
export DISCORD_APP_ID='your app id'      # optional, for the image steps
python3 scripts/live_test.py

Walks through 11 steps against the real gateway — plain card, images, elapsed timer, countdown, buttons, party counter, listening/watching types, a partial update, status changes, clear — printing what you should see for each one so you can check it against a Discord client. --list shows the steps, --step N runs just one (repeatable), --hold N changes how long each step lingers.

The token is read from the environment or a hidden prompt, never printed and never written to disk.

Files

customrpc/
  __init__.py     public API
  client.py       RichPresence, the high level interface
  gateway.py      websocket, op codes, heartbeat, reconnect
  presence.py     Presence dataclass and payload building
  assets.py       image URL -> mp:external key
  errors.py       exception types
  __main__.py     python -m customrpc
tests/            offline suite + fake gateway server
examples/         basic.py, advanced.py
scripts/          live_test.py

Getting a token

Discord does not offer a "copy my token" button, on purpose. Anyone holding it can act as you completely, so treat it like a password: keep it in an environment variable or a secrets manager, never in a committed file, and change your password to invalidate it if it leaks.

About

Set a Discord custom rich presence using Python

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages