Python wrapper
A Pycord-style Python wrapper for hackclub.tv, made by Christian. Build chat bots with decorators and minimal boilerplate.
Check it out on PyPI: https://pypi.org/project/hctvwrapper/ You can take a look at the code here
pip install hctvwrapperQuick Start
Section titled “Quick Start”import osfrom hctvwrapper import Bot
bot = Bot(command_prefix="!")
@bot.eventasync def on_ready(session): print(f"Logged in as {session.viewer.username}")
@bot.eventasync def on_message(message): print(f"{message.author.username}: {message.content}")
@bot.command()async def ping(ctx): await ctx.reply("pong!")
bot.run(os.environ['BOT_TOKEN'], channel="bot-playground")Getting a Bot Token
Section titled “Getting a Bot Token”- Go to hackclub.tv
- Create a bot account and get your API key (starts with
hctvb_) - Set it as an environment variable:
export BOT_TOKEN=hctvb_xxx
Events
Section titled “Events”Register event handlers with @bot.event. The function name determines which event it handles.
@bot.eventasync def on_ready(session): """Fired when the bot connects and receives session info.""" print(f"Logged in as {session.viewer.username}") print(f"Can moderate: {session.permissions.can_moderate}") print(f"Max message length: {session.moderation.max_message_length}")
@bot.eventasync def on_message(message): """Fired on every chat message.""" print(f"[{message.channel}] {message.author.username}: {message.content}") # message.author.id, .pfp_url, .display_name, .is_bot, # .is_platform_admin, .channel_role # message.msg_id, .timestamp, .type
@bot.eventasync def on_history(messages): """Fired once on connect with up to 100 recent messages.""" print(f"Got {len(messages)} historical messages")
@bot.eventasync def on_system_message(message): """Fired on system notifications (bans, unbans, etc.).""" print(f"System: {message.content}")
@bot.eventasync def on_message_deleted(event): """Fired when a message is deleted by a moderator.""" print(f"Message {event.msg_id} deleted in {event.channel}")
@bot.eventasync def on_chat_access(access, channel): """Fired when chat permissions change (timeouts, bans).""" print(f"Can send in {channel}: {access.can_send}") if access.restriction: print(f" Restriction: {access.restriction.type}")
@bot.eventasync def on_moderation_error(error, channel): """Fired when a moderation action or message is rejected.""" print(f"Error in {channel}: {error.code} — {error.message}") # error.code is one of: FORBIDDEN, RATE_LIMIT, SLOW_MODE, # TIMED_OUT, BANNED, MESSAGE_TOO_LONG, BLOCKED_TERM, # INVALID_TARGET, INVALID_REQUEST, NOT_FOUNDCommands
Section titled “Commands”Register commands with @bot.command(). The bot automatically parses messages starting with the prefix.
bot = Bot(command_prefix="!")
# Simple command, no arguments@bot.command()async def ping(ctx): await ctx.reply("pong!")
# Named command with aliases@bot.command(name="say", aliases=["echo", "repeat"])async def say_cmd(ctx, *, text): await ctx.send(text)
# Positional arguments (split by whitespace)@bot.command()async def greet(ctx, name, greeting="hello"): await ctx.reply(f"{greeting}, {name}!")
# Keyword-only (rest of message)# Use *, text to capture everything after the command as a single string:@bot.command()async def echo(ctx, *, text): await ctx.reply(text)# !echo hello world foo → text = "hello world foo"The bot automatically ignores its own messages to prevent loops.
Context
Section titled “Context”The ctx object passed to commands gives you everything you need:
@bot.command()async def info(ctx): ctx.message # the full Message object ctx.author # shortcut to ctx.message.author (Author) ctx.channel # channel name (str) ctx.bot # reference to the Bot
await ctx.reply("text") # sends "@username text" await ctx.send("text") # sends "text" without mention await ctx.delete() # deletes the triggering message (needs mod perms)Sending Messages
Section titled “Sending Messages”# Inside a commandawait ctx.reply("mentioned reply")await ctx.send("plain message")
# Anywhere (if you have a reference to the bot)await bot.send("hello!", channel="bot-playground")Multi-Channel
Section titled “Multi-Channel”Connect to multiple channels at once:
bot.run("hctvb_xxx", channels=["channel1", "channel2", "bot-playground"])Messages and commands work across all channels. Use ctx.channel or message.channel to know which channel a message came from.
@bot.eventasync def on_message(message): print(f"[{message.channel}] {message.author.username}: {message.content}")
@bot.command()async def where(ctx): await ctx.reply(f"you're in {ctx.channel}")Moderation
Section titled “Moderation”Bots with moderation permissions can manage users:
# Timeout a user for 5 minutes (default)await bot.timeout_user("channel", user_id="user123", duration=300, reason="spam")
# Ban a userawait bot.ban_user("channel", user_id="user123", reason="repeated violations")
# Remove a timeoutawait bot.lift_timeout("channel", user_id="user123")
# Unban a userawait bot.unban_user("channel", user_id="user123")
# Delete a specific messageawait bot.delete_message("channel", msg_id="msg-uuid")Emojis
Section titled “Emojis”Look up or search emojis from the Slack. Results come back via events.
# Look up emoji URLsawait bot.lookup_emojis(["yay", "aga"])
# Search emojisawait bot.search_emojis("yay")
# Handle results@bot.eventasync def on_emoji_response(emojis): # emojis = {"yay": "https://...", "aga": "https://..."} print(emojis)
@bot.eventasync def on_emoji_search(results): # results = ["yay", "yay-bounce", "yay-spin", ...] print(results)Async Entry Point
Section titled “Async Entry Point”If you manage your own event loop:
import asyncio
async def main(): bot = Bot(command_prefix="!")
@bot.event async def on_ready(session): print("Connected!")
await bot.start("hctvb_xxx", channel="bot-playground")
asyncio.run(main())Examples
Section titled “Examples”Echo Bot
Section titled “Echo Bot”from hctvwrapper import Botimport os
bot = Bot(command_prefix="!")
@bot.eventasync def on_ready(session): print(f"✅ Logged in as {session.viewer}")
@bot.command()async def ping(ctx): await ctx.reply("pong! 🏓")
@bot.command(name="echo", aliases=["say"])async def echo_cmd(ctx, *, text): await ctx.send(text)
bot.run(os.environ["BOT_TOKEN"], channel="bot-playground")AI Bot
Section titled “AI Bot”from hctvwrapper import Botimport aiohttp, os
bot = Bot(command_prefix="/")
@bot.command(name="ai")async def ai_cmd(ctx, *, prompt): async with aiohttp.ClientSession() as http: resp = await http.post( "https://ai.hackclub.com/proxy/v1/chat/completions", headers={"Authorization": f"Bearer {os.environ['AI_TOKEN']}"}, json={ "model": "google/gemini-3-flash-preview", "messages": [{"role": "user", "content": prompt}], }, ) data = await resp.json() answer = data["choices"][0]["message"]["content"] await ctx.reply(answer)
bot.run(os.environ["BOT_TOKEN"], channel="bot-playground")Moderation Bot
Section titled “Moderation Bot”from hctvwrapper import Botimport os
bot = Bot(command_prefix="!")
@bot.command()async def timeout(ctx, user_id, seconds="300"): await bot.timeout_user(ctx.channel, user_id, duration=int(seconds)) await ctx.send(f"⏰ Timed out for {seconds}s")
@bot.eventasync def on_moderation_error(error, channel): print(f"⚠️ {error.code}: {error.message}")
bot.run(os.environ["BOT_TOKEN"], channel="my-channel")Models Reference
Section titled “Models Reference”| Model | Fields |
|---|---|
Message | content, author, channel, msg_id, timestamp, type, is_bot |
Author | id, username, pfp_url, display_name, is_bot, is_platform_admin, channel_role |
Session | viewer (Author), permissions, moderation |
Permissions | can_moderate |
ModerationSettings | has_blocked_terms, slow_mode_seconds, max_message_length |
SystemMessage | type, channel, content, timestamp |
ChatAccess | can_send, restriction |
Restriction | type (timeout/ban), reason, expires_at |
ModerationError | code, message, restriction |
ModerationEvent | type, msg_id, channel |
Requirements
Section titled “Requirements”- Python 3.10+
websockets(only dependency)
License
Section titled “License”Copyright (c) 2026 Christian Well - MIT License