Discord to Roblox ban bot

Hey! I’m looking to make a Discord bot with some basic commands that connects with my Roblox game, and I want to ask how would I go about it.
I thought that the best way to approach it was to have Discord, where you do the command, a database to store all players banned (if needed also a middleman for the API requests) and lasty Roblox which will execute the code. For the database I think SupaBase would work fine, I thought about Google sheets but I don’t like that when the text gets larger than the cell it just no clips onto the next one.
The commands are: /ban UserId time reason (ban someone), /kick UserId (kick someone), /getBannedUsers (get the list of all banned players from the database) and /isUserBanned UserId (see in the database if someone is banned, if true for how long and why).
I know it’s possible because I’ve seen projects about this, but I want to make it myself, so I can learn with it. I know Luau and Python (only a bit), so if the coding can be confined to those 2 better.

4 Likes

Hey! These can be quite effectively implemented using the OpenCloud API. First, you need to make an API key with sufficient permissions. Just wondering why you want to use an external database for banned users though? You can access the ban API quite easily from OpenCloud too.

Basic structure and permissions

Banning

For banning, if you plan to use the Roblox ban API, you will need two permissions. look for something along the lines of universe.user-restriction:read and universe.user-restriction:write.

Kicking

For kicking, you’ll want to use MessagingService. You can use the OpenCloud API to send a message to game servers, and on each game server you can check for the player in Luau and kick them if present.

Look for something along the lines of universe-messaging-service:publish.

Provided you use the Roblox ban API

Anything related to banning will need to go through the UserRestriction API.

listing user restrictions (who is banned?)
get user restriction (read a player’s ban data)
update a user restriction
read a user’s restriction log (getting ban history)

as for kicking, you’ll want this endpoint for MessagingService:
messaging service v1


I don’t use Python often, but AFAIK there is a Discord.py library you can use to make the bot, and you can use the requests library to send HTTP requests.

This is a very basic overview, I hope it helps!

2 Likes

If I understand correctly, universe.user-restriction:read is for checking if he’s banned, and with :write to ban him?

well, yes, but not just that!

I apologise if you already know the following and/or this comes across as patronising, I’m unsure about your knowledge level on this topic.

let’s talk about it generically; if you have read permission for a piece of data, that means you can look at it and, well, read it - but you’re not allowed to change it unless you have write permissions. Write permissions let you change the data all you want, but you can’t look at it unless you have read permissions.

Here, you’re giving the API key both read and write permissions, so you’re basically saying, “we can read all of this data, and we can change whatever we want about it”. So you’re not just limited to “read is checking if they’re banned, write is for banning them”, you can read and write all of the data. You can do things like seeing when they were banned, what the reason was for banning them, how long they were banned for, et cetera. But yes, you would use read to check for ban, and write to ban them.

Roblox offers built in API endpoints that you can setup with a bot that will ban a user directly without any in game script.

https://create.roblox.com/docs/cloud/reference/UserRestriction#List-User-Restrictions

1 Like

Ok, I was understanding that it was a function to “read” if banned, and “write” a ban.

1 Like

So I would send an HTTPS request to “/cloud/v2/universes/{universe_id}/user-restrictions” with this table?

{
  "path": "universes/123/user-restrictions/123",
  "updateTime": "2023-07-05T12:34:56Z",
  "user": "users/156",
  "gameJoinRestriction": {
    "active": true,
    "startTime": "2023-07-05T12:34:56Z",
    "duration": "3s",
    "privateReason": "some private reason",
    "displayReason": "some display reason",
    "excludeAltAccounts": true,
    "inherited": true
  }
}

And it would ban the player?

Is there anyway to get the full list, either be it “pretty” in some part of the game’s config or with a script?

Well I have not used it before but read

This covers your four commands and keeps everything in Python + Luau.

Use three pieces: Discord bot, Supabase, Roblox. No extra “middleman” server needed. Push kicks/bans live with Roblox Open Cloud → MessagingService. Read bans from Supabase in-game with a read-only key + RLS.

Architecture

Discord bot (Python). Handles /ban, /kick, /getBannedUsers, /isUserBanned.
Writes to Supabase with service key.
Publishes live actions to Roblox via Open Cloud MessagingService.
Supabase. Stores bans. RLS allows read-only from Roblox.
Roblox (Luau, server).
On PlayerAdded, query Supabase for active ban.
Subscribe to moderation topic for instant kicks/bans.

1) Supabase table + RLS

SQL:

create table public.bans (
  id bigserial primary key,
  user_id bigint not null,
  reason text not null,
  until_ts timestamptz not null, 
  active boolean not null default true,
  created_at timestamptz not null default now()
);

create index on public.bans (user_id);
alter table public.bans enable row level security;

create policy anon_read_active_bans
on public.bans for select
to anon
using (active = true and now() < until_ts);

REST URL you will hit from Roblox:

GET https://<PROJECT>.supabase.co/rest/v1/bans?user_id=eq.<USERID>&active=eq.true&select=reason,until_ts
Headers:
  apikey: <ANON_KEY>
  Authorization: Bearer <ANON_KEY>
  Accept: application/json

Discord bot (Python, discord.py)

# bot.py
import os, json, re, datetime as dt, requests
import discord
from discord import app_commands
from supabase import create_client, Client
from dotenv import load_dotenv
load_dotenv()

DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
SUPABASE_URL  = os.getenv("SUPABASE_URL")
SUPABASE_KEY  = os.getenv("SUPABASE_SERVICE_KEY")   # service key
UNIVERSE_ID   = os.getenv("ROBLOX_UNIVERSE_ID")
OPEN_CLOUD_KEY= os.getenv("ROBLOX_OPEN_CLOUD_KEY")  # MessagingService key
TOPIC         = "moderation"

supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)

def publish_to_roblox(msg: dict):
    url = f"https://apis.roblox.com/messaging-service/v1/universes/{UNIVERSE_ID}/topics/{TOPIC}"
    body = {"message": json.dumps(msg)}
    r = requests.post(url, headers={
        "x-api-key": OPEN_CLOUD_KEY,
        "Content-Type": "application/json"
    }, json=body, timeout=10)
    r.raise_for_status()

def parse_duration(s: str) -> dt.timedelta:
    # 10m, 2h, 7d, 4w
    m = re.fullmatch(r"(\d+)([mhdw])", s.strip().lower())
    if not m: raise ValueError("Use 10m/2h/7d/4w")
    n, unit = int(m.group(1)), m.group(2)
    return {"m":dt.timedelta(minutes=n),
            "h":dt.timedelta(hours=n),
            "d":dt.timedelta(days=n),
            "w":dt.timedelta(weeks=n)}[unit]

class Bot(discord.Client):
    def __init__(self):
        intents = discord.Intents.none()
        super().__init__(intents=intents)
        self.tree = app_commands.CommandTree(self)

    async def setup_hook(self):
        await self.tree.sync()

bot = Bot()

@bot.tree.command(description="Ban a Roblox user")
@app_commands.describe(userid="Roblox UserId", time="10m/2h/7d/4w", reason="Why")
async def ban(interaction: discord.Interaction, userid: str, time: str, reason: str):
    try:
        uid = int(userid)
        delta = parse_duration(time)
        until = dt.datetime.utcnow() + delta
        # write ban
        supabase.table("bans").insert({
            "user_id": uid, "reason": reason, "until_ts": until.isoformat()+"Z", "active": True
        }).execute()
        # live notify game
        publish_to_roblox({"type":"ban","userId":uid,"reason":reason,"until":until.isoformat()+"Z"})
        await interaction.response.send_message(f"Banned {uid} for {time}: {reason}", ephemeral=True)
    except Exception as e:
        await interaction.response.send_message(f"Error: {e}", ephemeral=True)

@bot.tree.command(description="Kick a Roblox user now")
async def kick(interaction: discord.Interaction, userid: str):
    try:
        uid = int(userid)
        publish_to_roblox({"type":"kick","userId":uid})
        await interaction.response.send_message(f"Kicked {uid}", ephemeral=True)
    except Exception as e:
        await interaction.response.send_message(f"Error: {e}", ephemeral=True)

@bot.tree.command(description="Check if a user is banned")
async def isuserbanned(interaction: discord.Interaction, userid: str):
    uid = int(userid)
    res = supabase.table("bans").select("*").eq("user_id", uid).eq("active", True).execute()
    active = [r for r in res.data if dt.datetime.fromisoformat(r["until_ts"].replace("Z","+00:00")) > dt.datetime.utcnow()]
    if active:
        r = active[0]
        await interaction.response.send_message(
            f"Yes. Until {r['until_ts']}. Reason: {r['reason']}", ephemeral=True)
    else:
        await interaction.response.send_message("No active ban.", ephemeral=True)

@bot.tree.command(description="List active bans")
async def getbannedusers(interaction: discord.Interaction):
    res = supabase.rpc("",
        # simplest: query then filter in python
    )
    res = supabase.table("bans").select("user_id,reason,until_ts").eq("active", True).execute()
    rows = [r for r in res.data if dt.datetime.fromisoformat(r["until_ts"].replace("Z","+00:00")) > dt.datetime.utcnow()]
    text = "\n".join(f"{r['user_id']} | {r['until_ts']} | {r['reason']}" for r in rows) or "None"
    await interaction.response.send_message(f"Active bans:\n{text}", ephemeral=True)

bot.run(DISCORD_TOKEN)

.env:

DISCORD_TOKEN=xxx
SUPABASE_URL=https://xxxx.supabase.co
SUPABASE_SERVICE_KEY=xxx
ROBLOX_UNIVERSE_ID=1234567890
ROBLOX_OPEN_CLOUD_KEY=xxx   # MessagingService Publishing key

Roblox server script (Luau)

local HttpService = game:GetService("HttpService")
local Players = game:GetService("Players")
local MessagingService = game:GetService("MessagingService")

local SUPABASE_URL = "https://<PROJECT>.supabase.co/rest/v1/bans"
local ANON_KEY = "<SUPABASE_ANON_KEY>" 
local function isBanned(userId: number)
	local url = ("%s?user_id=eq.%d&active=eq.true&select=reason,until_ts"):format(SUPABASE_URL, userId)
	local ok, body = pcall(function()
		return HttpService:GetAsync(url, false, {
			["apikey"] = ANON_KEY;
			["Authorization"] = "Bearer " .. ANON_KEY;
			["Accept"] = "application/json";
		})
	end)
	if not ok then return false end
	local rows = HttpService:JSONDecode(body)
	if #rows == 0 then return false end
	local now = os.time()
	for _, r in ipairs(rows) do
		local untilIso = r.until_ts
		local untilUnix = DateTime.fromIsoDate(untilIso).UnixTimestamp
		if untilUnix > now then
			return true, r.reason, untilIso
		end
	end
	return false
end

local function kickBanned(p: Player)
	local banned, reason, untilIso = isBanned(p.UserId)
	if banned then
		p:Kick(("Banned until %s\nReason: %s"):format(untilIso, reason or ""))
	end
end

Players.PlayerAdded:Connect(kickBanned)

local TOPIC = "moderation"
MessagingService:SubscribeAsync(TOPIC, function(msg)
	local data = HttpService:JSONDecode(msg.Data)
	if data.type == "kick" and typeof(data.userId) == "number" then
		local plr = Players:GetPlayerByUserId(data.userId)
		if plr then plr:Kick("Kicked by staff.") end
	elseif data.type == "ban" and typeof(data.userId) == "number" then
		local plr = Players:GetPlayerByUserId(data.userId)
		if plr then plr:Kick(("Banned until %s\nReason: %s"):format(data.until or "N/A", data.reason or "")) end
	end
end)

Kicks are instant via Open Cloud → MessagingService. No polling hacks.
Bans persist in Supabase. Roblox reads with anon key constrained by RLS.
Only the bot holds the service key. Users cannot write bans.

Create bans table. Enable RLS. Add the read policy.
Create a MessagingService Open Cloud API key for your Universe.
Fill .env. Run the Python bot. Invite it to your server.
Add the Luau script to ServerScriptService. Add HttpService in Security settings.

1 Like

a full list of banned users should require one request and some JSON parsing.

Roblox APIs use paginated data, meaning one request should also include a nextPageToken field you can include in the next request to get the next page.

If you sent a GET request to:

https://apis.roblox.com/cloud/v2/universes/{universe_id}/user-restrictions

and give it your api key in a header, you should get back the ban data.

import requests

universe = #universe id here
api_key = "api key here"

def read_banned_users():
    url = f"https://apis.roblox.com/cloud/v2/universes/{universe}/user-restrictions"
    headers = {
        "x-api-key": api_key
    }
    response = requests.get(url, headers=headers)
    #from here, you can do things with the data received. For example, listing the user IDs of banned users in that page.

It gives you Discord-driven moderation that is instant in-game and persists between sessions.

/ban UserId time reason

  1. Discord bot writes a ban row to Supabase (user_id, reason, until_ts, active=true).
  2. Bot publishes a message to Roblox Open Cloud → MessagingService topic moderation.
  3. Your Roblox server receives it and immediately kicks the user if online.
  4. Later, on every join, the server queries Supabase; if ban not expired, the player is kicked with the reason.

/kick UserId
Bot publishes a kick message to the same topic.
Roblox server receives it and kicks the target if online. No DB write.

/getBannedUsers
Bot reads Supabase for all active bans whose until_ts is in the future.
Bot replies in Discord with the list.

/isUserBanned UserId
Bot looks up that user_id in Supabase for active, unexpired rows.
Bot replies with yes/no, until when, and the reason.

In-game enforcement path

PlayerAdded: server calls Supabase REST (read-only “anon” key + RLS policy).
If any active, unexpired ban exists → kick with reason and end time.
Live actions (kick/ban) arrive via MessagingService so you don’t poll.

Data model

Table bans(user_id bigint, reason text, until_ts timestamptz, active bool, created_at timestamptz).
Index on user_id.
RLS policy lets only select of active AND now() < until_ts with the anon key.
The Discord bot uses the service key to insert/update.

Security boundaries

Only the bot holds the service key (can write).
Roblox server holds the anon key (read-only).
No secrets on clients. No extra web server.

Failure behavior

If Discord or Open Cloud is down: kicks/bans made while offline will still apply on the next join because the ban is in Supabase.
If Supabase read fails on join: treat as not banned (your choice) or temporarily deny join with a retry.

Extend easily

Add /unban UserId → set active=false (or set until_ts in the past).
Add durations like 10m, 2h, 7d, 4w.
Add /reason UserId newReason to update the text.

I see, I understand it now, so if I’m correct, I WOULDN’T ban the players? I would only kick them and check every time they join if they’re “banned”?
Also, hipotheticaly, could I run a place inside the experience that solely exists to handle bans, make it not accessible to players?

Thanks for the reply, but I think that making it all in Roblox would be better, only reason I decided that maybe Supa Base was a good option was because that Roblox ban data storing would be a mess.

You can keep everything inside Roblox. Store bans in a DataStore and use Open Cloud MessagingService for instant actions from Discord. The game enforces on join and on live messages. If you need Discord to list or check bans, read the DataStore via Open Cloud; no third-party DB required.

How to do “all in Roblox”

  1. Schema (DataStore)
  • Bans store
    • key: u:<UserId>
    • value: { active: true, untilUnix: <number>, reason: "<text>" }
  1. Server enforcement (Luau)
local DS = game:GetService("DataStoreService"):GetDataStore("Bans")
local Players = game:GetService("Players")
local MessagingService = game:GetService("MessagingService")
local HttpService = game:GetService("HttpService")

local function isActive(ban)
	return ban and ban.active and os.time() < ban.untilUnix
end

local function enforce(p)
	local ok, ban = pcall(function() return DS:GetAsync("u:"..p.UserId) end)
	if ok and isActive(ban) then
		p:Kick(("Banned until %s\nReason: %s")
			:format(DateTime.fromUnixTimestamp(ban.untilUnix):ToIsoDate(), ban.reason or ""))
	end
end

Players.PlayerAdded:Connect(enforce)

MessagingService:SubscribeAsync("moderation", function(msg)
	local data = HttpService:JSONDecode(msg.Data)
	if data.type == "ban" then
		local key = "u:"..data.userId
		local untilUnix = data.untilUnix
		local reason = data.reason or ""
		pcall(function() DS:SetAsync(key, {active=true, untilUnix=untilUnix, reason=reason}) end)
		local plr = Players:GetPlayerByUserId(data.userId)
		if plr then plr:Kick(("Banned until %s\nReason: %s")
			:format(DateTime.fromUnixTimestamp(untilUnix):ToIsoDate(), reason)) end
	elseif data.type == "kick" then
		local plr = Players:GetPlayerByUserId(data.userId)
		if plr then plr:Kick("Kicked by staff.") end
	end
end)

  1. Discord bot side (Python)
  • Publish only to MessagingService via Open Cloud; the game writes the DataStore.
def publish(msg: dict):
    url = f"https://apis.roblox.com/messaging-service/v1/universes/{UNIVERSE_ID}/topics/moderation"
    requests.post(url, headers={"x-api-key": OPEN_CLOUD_KEY,"Content-Type":"application/json"},
                  json={"message": json.dumps(msg)}, timeout=10)

# /ban
publish({"type":"ban","userId": uid, "untilUnix": int(time.time())+duration_s, "reason": reason})

# /kick
publish({"type":"kick","userId": uid})

  1. /isUserBanned and /getBannedUsers from Discord
  • Option A: Use Open Cloud Data Store to GetEntry (key = u:<UserId>) and ListKeys with prefix u: to enumerate active ones, then filter untilUnix > now.
  • Option B: Skip listing from bot; have the game post ban/unban logs to a Discord webhook and let moderators read from that log.

I don’t think I need DataStores from what I understood from @12345koip, I can acces if banned without Data Storing it.

No, this would all go through the Ban API - the same one which Players:BanAsync and the likes interact with. Accessing it via OpenCloud is entirely possible and you can update it through OpenCloud too. That’s the UserRestriction stuff I talked about before - I linked the endpoints in my first reply.

Ok yeah, if I’m correct when you banned the player you put the time, and Roblox would automatically unban them for you when the time comes?

1 Like

that’s right!

although you should note the ban API format is slightly different, and the core ban fields differ too.

For example, you need to pass a start time in ISO format, and for duration, you need to give it as a string with s on the end.

For these in Python, you can use datetime.utcnow().isoformat() + "Z" (i think) and str(durationInSeconds) + "s" (respectively).

What’s the + “Z” for? And where/how do you get the “durationInSeconds” variable?