← Documentation  /  Guide du développeur

Créer un plugin

La référence complète du SDK pour les développeurs de plugins — Python sandboxé, livré sous forme de zip, distribué via la marketplace.

These docs are publicly viewable. To publish a plugin you'll need a free YourBot account: sign in to access the Dev Portal.
SDK v0.8.4

Les plugins sont des processus Python isolés. Écrivez un gestionnaire, déclarez vos capacités, envoyez un zip — nous l'exécutons dans un conteneur Docker verrouillé et lui transmettons les événements Discord via JSON-RPC.

Magasin clé-valeur

Magasin JSON par serveur. Les clés sont cloisonnées à votre plugin et au serveur actif — vous ne pouvez pas lire les clés d'un autre plugin, et vos clés pour le serveur A sont isolées du serveur B. Capacité : storage:kv.

Obtenir / définir / supprimer

ctx.kv.set("welcome_channel", "1234567890")
ctx.kv.set("session", {"user": "alice"}, ttl_seconds=3600)
val = ctx.kv.get("welcome_channel")
ctx.kv.delete("session")
exists = ctx.kv.exists("welcome_channel")

Compteurs

ctx.kv.increment("messages_today")            # +1
ctx.kv.increment("xp:user:42", amount=10)
ctx.kv.increment("stats", path="voice.hours") # dot-path into a JSON value
ctx.kv.decrement("lives_remaining")

increment(key, amount=1, path="") is atomic in a single round-trip — use it instead of get + modify + set. path targets a nested field inside a JSON object value.

En masse & listing

keys   = ctx.kv.list(prefix="user:", limit=100)          # [key, key, ...]
values = ctx.kv.list_values(prefix="user:", limit=100)   # {key: value}
batch  = ctx.kv.get_many(["a", "b", "c"])                # {key: value}, max 50
ctx.kv.set_many({"a": 1, "b": 2})                        # max 25 pairs
total  = ctx.kv.count(prefix="xp:")

list() returns key names only. When you need the stored values too, use list_values() — it avoids the N+1 of list + get.

Quotas. 50,000 keys per (server, plugin) plus a global cap of 500,000 keys per plugin across all servers, 64 KB max value, 512-byte key names. Exceeding a key-count quota raises KvQuotaError; an oversize key or value fails with a plain error. list() returns at most 1000 keys and list_values() at most 100 entries per call, so paginate with prefix ranges on big keyspaces.

Utilisateurs autorisés capacité

For third-party API keys, signing secrets and webhook tokens use ctx.secrets instead of ctx.kv. Secrets are encrypted at rest (AES-GCM with the platform master key) and stay out of your repo. The platform never writes secret values into your stdout, logs or KV, but nothing redacts a value your own code prints or logs, so treat ctx.secrets.get results as sensitive. Where you can, prefer secret-backed auth so your code never touches the value at all. Capability: storage:secrets.

Two scopes: an optional per-server override set at runtime (checked first), then the dev-level default you set once in the Dev Portal for every server that installs your plugin.

# Read: per-server override first, then your dev-portal default, else None
api_key = ctx.secrets.get("OPENAI_API_KEY")
if not api_key:
    ctx.log("No API key configured for this server", level="warning")
    return

ctx.secrets.set("OPENAI_API_KEY", submitted_key)   # per-server override
ctx.secrets.delete("OPENAI_API_KEY")               # remove the override only

Key format: 1–64 chars, letters/digits/_/-/. only. Values: 1–4096 UTF-8 chars.

Secrets also power secret-backed auth: name a stored secret in an ctx.http or ctx.ws call and the platform sends it as the Authorization header without your code ever reading it.

SQL cloisonné capacité

For relational data heavier than KV. Each (plugin, server) pair gets its own isolated Postgres schema: your tables for server A and server B are physically separate, so you never need a server_id column and cannot query across servers. Capability: storage:sql — servers consent to it at install time, and it lands in the normal staff review every new developer's submissions go through.

ctx.sql.execute(
    "CREATE TABLE IF NOT EXISTS scores ("
    "  user_id TEXT PRIMARY KEY,"
    "  points  INT NOT NULL DEFAULT 0)"
)

ctx.sql.execute(
    "INSERT INTO scores (user_id, points) VALUES (%s, %s) "
    "ON CONFLICT (user_id) DO UPDATE SET points = scores.points + EXCLUDED.points",
    ["user_42", 5],
)

rows  = ctx.sql.query("SELECT * FROM scores ORDER BY points DESC LIMIT 10")
top   = ctx.sql.query_one("SELECT * FROM scores WHERE user_id = %s", ["user_42"])
total = ctx.sql.scalar("SELECT COUNT(*) FROM scores")

Use %s placeholders (psycopg style); ? and $1 are not accepted. Never build SQL with f-strings or concatenation: it is injection-prone, the upload scan flags it and it will fail review. Queries return at most 1000 rows (hard cap; limit=N only fetches fewer) and the cut is silent — ctx.sql.query returns just the rows, so page with LIMIT/OFFSET or run a COUNT(*) first when the table can outgrow the cap. One statement per call from an allowlist (SELECT, INSERT, UPDATE, DELETE, CREATE/ALTER/DROP TABLE, CREATE/DROP INDEX). Quotas: 100 tables and 10,000,000 rows per (plugin, server), and schema-changing DDL is limited to 20 statements/hour (idempotent IF NOT EXISTS re-runs are exempt).

État éphémère v0.5.0

Soutenu par Redis — compteurs rapides, déduplication, délais de recharge et indicateurs de courte durée. N'utilisez pas ceci pour ce dont vous avez besoin de manière durable (la politique d'éviction est LRU sous pression mémoire). Aucune capacité requise.

Compteurs

n = ctx.ephemeral.counter("ratelimit:user:42", window_seconds=60)
if n > 5:
    ctx.interaction.respond(content="Slow down!", ephemeral=True)
    return

Délais de recharge

state = ctx.ephemeral.cooldown_check("daily:user:42")
if state["active"]:
    ctx.interaction.respond(
        content=f"Try again in {int(state['remaining_seconds'])}s",
        ephemeral=True,
    )
    return
ctx.ephemeral.cooldown_set("daily:user:42", ttl_seconds=86400)

Déduplication & indicateurs

# dedup: returns True the FIRST time a key is seen, False afterwards
if not ctx.ephemeral.dedup(f"welcome:{event['user_id']}", ttl_seconds=3600):
    return  # already welcomed this user in the last hour

ctx.ephemeral.flag_set("event:black_friday", ttl_seconds=86400)
if ctx.ephemeral.flag_check("event:black_friday"):
    ...

Limits: keys up to 256 chars, max TTL 24 hours (TTLs above 86400s are silently clamped). Reads are free; writes draw from your 60 outbound actions/min budget at 0.1 each, so roughly 600 ephemeral writes/min if you do nothing else. State is namespaced per (plugin, server): like KV, nothing is shared across servers, so a flag set on server A is invisible on server B. See Limits and quotas.

HTTP sortant

Les plugins ont --network none dans le sandbox — chaque requête sortante passe par le proxy de la plateforme. Capacité : proxy:http.

resp = ctx.http.get("https://api.example.com/v1/status")
print(resp["status"], resp["body_bytes"])

resp = ctx.http.post(
    "https://api.example.com/webhook",
    body='{"hello": "world"}',
    headers={"Content-Type": "application/json"},
)

# Query-string params - values may be strings or lists (repeated keys)
resp = ctx.http.get("https://api.example.com/search",
    params={"q": "test", "id": ["123", "456"]})

# Custom method
resp = ctx.http.request("PATCH", "https://api.example.com/x/1", body='{"a":1}')

Forme de la réponse : {"status": int, "body_bytes": str, "headers": dict, "truncated": bool}. Le corps est limité à 1 Mo ; truncated=True si tronqué. Le proxy applique une limite de 30 requêtes/minute par (serveur, plugin).

  • Domain allow-list: only hostnames you declared (manifest or Dev Portal) are reachable. Subdomains are included automatically, so example.com also allows api.example.com.
  • Stripped headers: Authorization, Host, Cookie, Proxy-Authorization, X-Forwarded-* and other smuggling-prone headers are removed. For Bearer-token APIs use secret-backed auth below. For APIs with their own header, a custom header like X-Api-Key passes through. Never put keys in the URL: query strings end up in logs.
  • No redirects, no cookies, no private IPs: redirects are not followed, cookies are never stored, and loopback/private/reserved ranges are rejected.

Authenticated requests (secret-backed auth) v0.8.0

You cannot set Authorization yourself, and you should not hold raw API keys in code. Instead, store the key with ctx.secrets and name it in the call: the platform injects the header server-side and your plugin never sees the value. The secret must be bound to the destination domain (set its allowed domains in the Dev Portal), so a leaked plugin can't replay it elsewhere. Requires storage:secrets.

# Authorization: Bearer <value of MY_API_KEY> is injected by the platform
resp = ctx.http.request("GET", "https://api.example.com/v1/me",
                        secret_auth="MY_API_KEY")

# Non-bearer schemes
resp = ctx.http.request("POST", "https://api.example.com/v1/charge",
                        body='{"amount": 5}',
                        auth={"scheme": "basic", "secret": "MY_API_KEY"})

Note secret_auth and auth live on ctx.http.request(...); the get/post shorthands do not take them.

WebSockets v0.8.0

For live feeds a request-per-poll is the wrong shape. ctx.ws keeps a persistent two-way connection open to a host you declared, held by the platform's broker (your sandbox still has no network access) and reconnected automatically. Capability: proxy:websocket.

@plugin.on_slash_command("feed-start")
def start_feed(ctx, event):
    # Idempotent: call it whenever you need the connection alive.
    ctx.ws.ensure(
        "prices",
        "wss://stream.example.com/v1",
        secret_auth="STREAM_KEY",              # optional, injected on connect
        subscribe=['{"op": "subscribe", "channel": "ticker"}'],
    )
    ctx.interaction.respond(content="Feed running.")

@plugin.on_ws_message("prices")
def on_frame(ctx, msg):
    # msg = {"name", "conn_id", "data", "binary"}; binary data is base64
    ctx.kv.set("last_tick", msg["data"])

@plugin.on_ws_close("prices")
def on_close(ctx, msg):
    ctx.log("price feed closed", level="warning")
  • ensure(name, url, *, secret_auth=None, auth=None, subscribe=None, binary=False) opens or confirms the named connection. subscribe frames are re-sent after every reconnect, so subscriptions survive drops. Set binary=True for binary protocols; inbound binary frames arrive base64-encoded.
  • ctx.ws.send(name, data) sends one frame (str is a text frame, bytes a binary frame). ctx.ws.close(name) hangs up.
  • Handlers: @plugin.on_ws_message(name), on_ws_open, on_ws_close. Since v0.8.2 a wildcard like "rustplus:*" matches every connection sharing the prefix; the concrete name is in the frame. Frames for one connection arrive in order. A broker error surfaces as a close, there is no separate error handler.
  • Hosts: the exact host must be in your proxy_domains_requested. When the server admin supplies the address at setup time (their own game server, say), call ctx.ws.allow_host(host) from a slash handler run by that admin; the platform verifies the member is an admin and the host is public, then remembers it for that server. ctx.ws.revoke_host(host) undoes it.
  • Tier and quotas: proxy:websocket is a staff-reviewed capability. Unlike HTTP, the WebSocket allow-list matches the exact host (no automatic subdomains). Budgets per (server, plugin): 6 connects/min and a single 4 MB/min traffic budget shared across both directions.
  • Local testing: MockContext.ws records allowed_hosts and revoked_hosts, and yourbot validate detects ctx.ws usage so the capability isn't missing at upload.

Métriques v0.5.0

Compteurs de séries temporelles avec des tags optionnels. Apparaissent sur le tableau de bord de votre plugin via ctx.metrics.query.

ctx.metrics.record("commands_used")                            # +1
ctx.metrics.record("xp_awarded", value=25)
ctx.metrics.record("commands_used", tags={"command": "ban"})

# Read back
trend = ctx.metrics.query("commands_used", period="7d", group_by="command")
# {"labels": [...], "series": [{"name": "...", "data": [...]}], "total": float}
total = ctx.metrics.total("xp_awarded", period="30d")

period accepte 1h, 24h, 7d, 30d, 90d. Agrégats : sum (par défaut), avg, max, min.

Limits: 1,000,000 stored data points per (server, plugin), 10 tag pairs per record (extras are silently dropped), tag values truncated to 128 chars, 90-day query window.

Unlike ctx.log, metrics calls are blocking RPCs that can raise (RateLimitError, RpcTimeoutError). If you record in the hot path of a command handler, wrap the call in try/except so a transient metrics failure can't break the user-visible work.

Journalisation

Les journaux du plugin apparaissent dans le visualiseur de journaux du portail développeur avec l'horodatage complet, le niveau et les tags.

ctx.log("Plugin booted")
ctx.log("User joined", level="info", tags=["onboarding"])
ctx.log("Discord error: " + str(exc), level="error", tags=["discord"])
ctx.log("Custom payload", request_id=ctx.request_id, user_id=event["user_id"])

Levels: info (default), warning, error. Messages cap at 4000 chars, up to 10 tags, and any extra **kwargs (up to 20, 500 chars per value) are stored as JSON detail. ctx.log is fire-and-forget: it does not wait for or check a response.

Inside an interaction handler, ctx.request_id returns a stable correlation ID for the current event — thread it through your log calls so one user action can be grepped as a unit.

SDK v0.8.4 · Dernière mise à jour juillet 2026 · Retour au portail développeur