Project overview
This project ties together Discord role management and Sierra Chart’s licensing API.
A persistent button in a chosen Discord channel launches a modal where users can submit their Sierra Chart username.
The bot records a mapping between Discord username and Sierra username in a local SQLite database,
and uses Sierra’s CustomDLLStudiesManagement API to grant or revoke access to a compiled study.
Problem solved
Manually onboarding and offboarding users for paid Sierra Chart studies is error‑prone: each change requires editing usernames in Sierra’s control panel, keeping a separate list in Discord, and remembering to revoke access when someone leaves or loses a subscription role.
- No single place that knows “who in Discord should have access to this Sierra study”.
- Onboarding relies on DMs and spreadsheets instead of a structured flow.
- Revoking access when a user leaves or loses a role is easy to forget.
The bot replaces this with a simple, auditable workflow driven directly from Discord and synchronized to Sierra.
Key outcomes
- One‑click onboarding: members press a button and submit their Sierra username in a modal.
- Automatic grant/revoke of study access via Sierra’s HTTP API.
- SQLite database that tracks Discord ↔ Sierra username mappings and access flags.
- Hooks on member leave and role changes to keep Sierra access in sync with Discord roles.
How it works
At a high level, the bot exposes a persistent “Fill in Your Sierra Info” button in a configured channel. When users interact with that button, their Sierra username is stored, and the Sierra API is called to add or remove them from the DLL study’s access list. Separate event handlers react to membership and role changes so that Discord and Sierra stay aligned.
- Persistent Discord button – on startup, the bot posts (or reuses) a message with a
discord.ui.Viewthat contains a primary button labelled “Fill in Your Sierra Info”. - Modal for username capture – clicking the button opens a modal asking for the user’s
Sierra Chart username, implemented via
discord.ui.Modal. - SQLite‑backed mapping – the submitted username is written to a
username_mappingtable keyed by Discord username and username type (here:sierra_chart), with anaccessflag. - Sierra API integration – for each add/remove, the bot calls
https://www.sierrachart.com/API.phpwith the admin credentials, requestingCustomDLLStudiesManagementactions for the specific username. - Membership & role hooks – when a member leaves the server or loses a target role,
the bot looks up their mapped Sierra usernames and sends
removeactions to Sierra, then flips the localaccessflag to0. - Regain handling – when a user regains the required role, the bot re‑adds their Sierra usernames
to the study and restores
access=1in the database.
Key technologies used
The implementation is intentionally small and focused, built around discord.py and a file‑backed SQLite database, so it can run locally or inside a container.
- Python + discord.py – command prefix bot with intents for members and message content enabled.
- Discord UI components –
discord.ui.View, buttons, and modals for a clean registration flow. - SQLite – lightweight persistent store for username mappings with simple schema migration logic.
- Sierra Chart HTTP API –
CustomDLLStudiesManagementservice for adding/removing users from a DLL study. - Structured logging – file and console logging for all API calls, DB changes, and membership events.
- Environment‑driven configuration – Discord token and Sierra admin credentials are loaded from environment variables.
Representative code excerpt
The heart of the project is a modal submission handler that updates the local mapping table and synchronizes the result to Sierra’s API.
Username capture and Sierra sync
class InfoModal(ui.Modal, title="Share Your Sierra Username"):
full_name = ui.TextInput(label="Sierra Chart Username")
async def on_submit(self, interaction: Interaction):
discord_user = f"{interaction.user.name}"
new_username = self.full_name.value
# Look up existing mappings
cursor = db_conn.cursor()
rows = cursor.execute(
"SELECT mapped_username FROM username_mapping "
"WHERE discord_username = ? AND username_type = ?",
(discord_user, "sierra_chart")
).fetchall()
# Insert or update row, then call Sierra API with "add"/"remove" actions
# depending on whether the username changed.
The production code handles several edge cases: users updating existing usernames, API failures (including rolling back DB rows on error), and composing a human‑readable summary of Sierra API responses back to the user.
v2 — Multi-tenant, licensed product
The single-server bot above proved the workflow; v2 rebuilds it as a product any indicator vendor can use. One bot instance serves many Discord servers, each belonging to a "buyer" who onboards themselves with a license key — turning bespoke automation into per-seat SaaS.
Multi-tenancy & licensing
- A server admin runs
!setup_buyer: a modal collects their Lemon Squeezy license key, Sierra admin credentials, study name, and optional access-granting roles — validated against the licensing API before anything is persisted. - Anti-key-sharing at the data layer: the first Discord user to redeem a key owns it; they may reuse it across their own servers, anyone else is rejected.
- License validation cross-checks product and variant IDs, so a key for a cheaper product cannot unlock this one, with distinct human-readable messages per failure mode.
- Tenant data model: buyers → guilds → settings → username mappings with a composite primary key and cascade deletes;
username_typeis already generalized so a second vendor platform needs no migration.
Reliability & security details
- Sierra admin passwords are encrypted at rest (Fernet), with a documented dev-vs-prod key split.
- One stable button
custom_idfor every tenant — the buyer is resolved from where the click happened (guild + channel), sidestepping unbounded component growth. - Self-healing UI: on every startup the bot verifies its registration button still exists in each configured channel and reposts it only if the message or component is gone.
- Compensating writes: if the Sierra API call fails after the local row was inserted, the row is deleted — the database never claims an entitlement the vendor panel doesn't have.
- Runs as a non-root Docker container targeting AWS Fargate, fully env-driven with fail-fast config validation.
Tenant resolution by location, not by button identity
@ui.button(label="Fill in Your Sierra Info", style=ButtonStyle.primary,
custom_id=PERSISTENT_BUTTON_CUSTOM_ID)
async def open_modal(self, interaction: Interaction, button: ui.Button) -> None:
# Same custom_id for all servers is intentional: we resolve buyer from WHERE the
# click happened (guild_id + channel_id), not from the button id.
row = db_module.get_buyer_for_guild_channel(
db_conn, interaction.guild.id, interaction.channel.id)
if not row:
await interaction.response.send_message(
"This channel is not set up for Sierra registration. "
"An admin should run `!setup_buyer` here first.", ephemeral=True)
return
await interaction.response.send_modal(
InfoModal(buyer_id=row["buyer_id"], guild_id=interaction.guild.id))