raycord
=======

A raylib-simple wrapper around discord.py.  One global bot, plain functions,
and almost no ceremony:

    import raycord

    raycord.init("YOUR_TOKEN", prefix="!")

    @raycord.on_ready
    def ready():
        print("online as", raycord.user())

    @raycord.command("ping")
    def ping(ctx):
        raycord.reply(ctx, "pong")

    raycord.run()

Nothing needs `await`.  Every helper starts its discord.py coroutine right
away and returns a RayTask, so you can:

    raycord.send(channel, "hi")                 # fire and forget
    raycord.send(channel, "hi").result()        # block from sync code
    await raycord.send(channel, "hi")           # or await from async code

Under it all is the real discord.py: if raycord does not wrap something, reach
for `raycord.discord`, `raycord.commands` or `raycord.app_commands`, or the bot
itself via `raycord.bot()`.


Install
-------

    pip install discord.py      # raycord's only hard dependency
    pip install PyNaCl          # only for voice

Copy the `raycord/` folder into your project, or `pip install .` from this
directory.  Python 3.9+.


Documentation
-------------

Full guides live in `docs/`:

    docs/index.md           overview, install, first bot, concepts
    docs/async.md           RayTask and sync vs async handlers
    docs/commands.md        prefix, slash, groups, context menus, checks, cooldowns
    docs/events.md          event decorators and error handling
    docs/ui.md              buttons, select menus, modals, persistent views
    docs/voice.md           voice channels and FFmpeg playback
    docs/guilds.md          channels, threads, roles, emoji, events, moderation
    docs/cogs.md            grouping code and hot reloading
    docs/api-reference.md   every public function and class

The quick version follows.


The shape of the API
--------------------

Lifecycle
    init(token, prefix="!", intents="default", shard="none", sync=False)
    run() / start() / launch()
    shutdown() / stop(), reset()
    bot(), user(), guilds(), get_guild(), get_channel(), get_user(), get_member()
    is_ready(), wait_until_ready(), sync(guild=None), set_status(...)

Events
    @on_ready, @on_message, @on_member_join, @on_reaction_add, ...
    @on("thread_create")                      # any discord.py event
    @on_command_error, @on_app_error
    add_listener(handler, "event")

Classic commands
    @command("ping", aliases=["p"], help="pong")
    def ping(ctx, member: discord.Member):
        reply(ctx, "pong")

Slash commands
    @slash("hello", description="say hi", describe={"name": "your name"})
    def hello(interaction, name: str = "world"):
        reply(interaction, f"hi {name}")

    admin = slash_group("admin", "admin tools")

    @admin.command("ban")
    def ban(interaction, member: discord.Member):
        reply(interaction, "banned")

Context menus
    @user_command("High Five")
    def high_five(interaction, member):
        reply(interaction, "hi")

    @message_command("Report")
    def report(interaction, message):
        reply(interaction, "reported")

Checks and cooldowns (work on prefix and slash alike)
    @command("ban")
    @cooldown(1, 5, "user")
    @has_permissions(ban_members=True)
    def ban(ctx, member: discord.Member): ...

    also: is_owner(), guild_only(), dm_only(), nsfw_only(), bot_has_permissions(),
          has_role(), has_any_role(), check(predicate)

UI: buttons, menus, modals
    @command("menu")
    def menu(ctx):
        reply(ctx, "Pick one", view=view(
            button("Yes", "yes", style="success"),
            button("No", "no", style="danger"),
        ))

    @on_button("yes")
    def said_yes(interaction):
        reply(interaction, "yes!")

    @on_select("pick")
    def picked(interaction, values):
        reply(interaction, values)

    form = modal("Feedback", [field("Name"), field("Message", style="long")], id="fb")
    show_modal(interaction, form)

    @on_modal("fb")
    def submitted(interaction, values):
        reply(interaction, values["Name"])

Messages
    send(target, content, view=..., embed=..., ephemeral=True)
    reply(target, content, ...)
    edit(target, content=...), delete(target), react(target, emoji)
    pin(target), dm(user, content), fetch_message(channel, id)

Voice (needs FFmpeg on PATH and PyNaCl installed)
    join_voice(ctx), leave_voice(ctx)
    play(ctx, "song.mp3", volume=0.5, wait=True)
    stop_voice(), pause_voice(), resume_voice(), is_playing()

Guild management
    create_text_channel(guild, "name"), create_voice_channel(...),
    create_category(...), create_forum(...), create_channel(guild, "name", kind="forum")
    create_thread(channel, "name"), create_forum_post(forum, "title", "body")
    create_role(guild, "Mod", color=0x00FF00, hoist=True)
    create_emoji(guild, "name", image_bytes), create_sticker(guild, "name", file)
    create_event(guild, "Movie night", start_time, channel=vc)
    edit(obj, **fields), delete(obj), kick(member), ban(user), unban(guild, user)

Cogs (reload a file without restarting the bot)
    class Admin(raycord.Cog):
        @command("ban")
        def ban(self, ctx, member: discord.Member):
            reply(ctx, "banned")

        @slash("kick")
        def kick(self, interaction, member: discord.Member):
            reply(interaction, "kicked")

        @on_message
        def watch(self, message):
            ...

    add_cog(Admin())
    remove_cog(Admin)
    reload_cog(Admin)      # re-imports the module and swaps the cog

Intents
    init(token, intents="default")   # guilds + messages + message_content
    intents="minimal"                # no privileged intents
    intents="all"                    # everything (enable in the dev portal)
    intents("default", members=True) # tweak specific flags

    Privileged intents (message content, members, presence) must also be turned
    on in the Discord Developer Portal.

Scaling
    init(token, shard="auto")        # discord.py's AutoShardedBot

Slash commands
    Run sync() once after changing them.  During development, sync to one guild
    with sync(guild=some_guild) — it is instant, unlike global sync.


How the raylib feel works
-------------------------

* One implicit context, like raylib's window: `raycord.init` creates it and the
  decorators attach to it.  `raycord.reset()` clears it.
* Functions are verbs, not objects: `send`, `reply`, `button`, `play`, `ban`.
* No `await` tax, no passing the bot around, no cogs required.  Cogs are there
  when a project grows.
* Automatic rate limiting, auto-sharding, converters, checks and the whole
  discord.py ecosystem still apply underneath.


Notes and limits
----------------

* A `view(...)` with the default `timeout=None` is registered as a persistent
  view, so buttons on old messages keep working after a restart, as long as the
  same `@on_button` / `@on_select` handlers run at startup.
* Plain `def` handlers run on the event loop, so do not block in them.  When you
  need a return value, write the handler as `async def` and `await` the RayTask
  (`thread = await create_thread(channel, "hi")`).  `.result()` is meant for code
  outside the loop (scripts, threads) and raises if called on the loop thread.
* `reload_cog` re-imports the whole module, so keep cog classes at module level.
* Voice needs `ffmpeg` installed and `PyNaCl` for the underlying voice lib.
* Tests: `python -m pytest`.


License
-------

MIT.  See LICENCE.txt.
