Zum Inhalt

Cache und Berechtigungen

Bostie lässt einen Knochen in eine Kiste fallen

bot.cache behält, was das Gateway dem Bot mitgeteilt hat, sodass Handler Dinge ohne Anfrage nachschlagen können. Er wird aus Events gefüllt, nie aus REST-Aufrufen, die du machst.

import bostie
from bostie import Context, Intents, PartialChannel, Permissions, fmt

bot = bostie.Bot(
    intents=Intents.DEFAULT | Intents.GUILD_MEMBERS,
    members=True,
    max_messages=5000,
)


@bot.command
async def server(ctx: Context) -> None:
    """Describe this server from the cache."""
    guild = bot.cache.guild(ctx.guild_id or 0)
    if guild is None:
        await ctx.respond("I only know servers I am in.", ephemeral=True)
        return
    channels = bot.cache.guild_channels(guild.id)
    in_voice = bot.cache.guild_voice_states(guild.id)
    members = bot.cache.guild_members(guild.id)
    created = fmt.timestamp(guild.created_at, "R")
    await ctx.respond(
        f"{guild.name}: {len(channels)} channels, {len(members)} members, "
        f"{len(in_voice)} in voice, created {created}"
    )


@bot.command
async def can_post(ctx: Context, channel: PartialChannel) -> None:
    """Check whether the bot can post in a channel."""
    allowed = bot.cache.permissions(channel.id)
    if allowed is None:
        await ctx.respond("I do not know that channel.", ephemeral=True)
    elif Permissions.SEND_MESSAGES in allowed:
        await ctx.respond(f"I can post in {channel.mention}.", ephemeral=True)
    else:
        await ctx.respond(f"I cannot post in {channel.mention}.", ephemeral=True)

Was er behält

Behalten Standardmäßig Option
Server, mit ihren Rollen, Emojis und Stickern ja
Kanäle und aktive Threads ja
Sprachzustände ja
geplante Events, Stage-Instanzen und Soundboard-Sounds ja
der Benutzer des Bots und sein eigenes Mitglied auf jedem Server ja
alle Mitglieder nein members=True, braucht Intents.GUILD_MEMBERS
Präsenzen von Mitgliedern, die nicht offline sind nein presences=True, braucht Intents.GUILD_PRESENCES
die neuesten Nachrichten nein max_messages=N
die neuesten DM-Kanäle nein max_dm_channels=N

Bot(cache=False) behält nichts, und die übrigen Optionen lösen dann ValueError aus. Eine Option, deren Intent fehlt, löst beim Erstellen des Bot MissingIntent aus. Kanäle werden so gespeichert, wie Discord sie gesendet hat, außer den Feldern, die CHANNEL_UPDATE nicht aktuell hält (last_message_id, message_count, member_count, last_pin_timestamp); diese sind None.

Mit members=True sendet Discord beim Verbinden des Bots nur einen Teil der Mitglieder großer Server. bostie fragt dann den Rest an, pro Shard einen Server nach dem anderen, und ein Shard gilt erst als bereit, wenn seine Server vollständig sind. chunk_members=False überspringt das, und der Cache füllt sich, während Mitglieder beitreten, sich ändern oder sprechen. Anfragen, die ein Rate-Limit treffen, werden nach Discords Verzögerung wiederholt.

Nachschlagen

Methode Gibt zurück
guild(id), guilds() einen Guild oder alle
channel(id), guild_channels(guild_id) einen Channel, oder die Kanäle eines Servers mit seinen aktiven Threads
role(id), guild_roles(guild_id) eine Role, oder die Rollen eines Servers
voice_state(guild_id, user_id), guild_voice_states(guild_id) wo Benutzer in Sprachkanälen sind; None, wenn der Benutzer in keinem Sprachkanal ist
scheduled_event(id), guild_scheduled_events(guild_id) ein ScheduledEvent, oder die eines Servers
stage_instance(id), guild_stage_instances(guild_id) eine laufende StageInstance, oder die eines Servers
soundboard_sound(sound_id), guild_soundboard_sounds(guild_id) einen SoundboardSound, oder die eines Servers
me(guild_id) das eigene Member des Bots auf einem Server
member(guild_id, user_id), guild_members(guild_id) Mitglieder; nur das eigene des Bots, außer bei members=True
presence(guild_id, user_id), guild_presences(guild_id) Präsenzen, mit presences=True; None, wenn offline
user(id) den Benutzer des Bots oder den Benutzer eines gecachten Mitglieds oder DM-Kanals
dm_channel(user_id) den DM-Kanal des Bots mit einem Benutzer, falls behalten
message(id) eine der neuesten max_messages Nachrichten
permissions(channel_id, member=None) die Berechtigungen eines Mitglieds in einem Kanal, die des Bots, wenn member fehlt
guild_permissions(guild_id, member=None) dasselbe, serverweit

Für alles, was der Cache nicht enthält, geben Abfragen None oder eine leere Liste zurück. bot.cache.clear() leert ihn. Update- und Delete-Events enthalten das gecachte Objekt von vor der Änderung, wie Events erklärt.

Der Cache, sonst REST

import bostie
from bostie.events import ReactionAdd

bot = bostie.Bot(max_messages=1000)
STAR = "\N{WHITE MEDIUM STAR}"


@bot.listen
async def starred(event: ReactionAdd) -> None:
    if event.emoji.name != STAR:
        return
    message = await bot.lookup.message(event.channel_id, event.message_id)
    user = await bot.lookup.user(event.user_id)
    await bot.rest.messages.create(
        event.channel_id,
        f"{user.username} starred a message by {message.author.username}.",
        reply_to=message.id,
    )

bot.lookup gibt ein Objekt aus dem Cache zurück, wenn er es enthält, und fragt sonst per REST danach: user(user_id), member(guild_id, user_id), channel(channel_id), guild(guild_id), role(guild_id, role_id) und message(channel_id, message_id). Was REST zurückgibt, wird nicht gespeichert: Der Cache folgt weiterhin nur dem Gateway, sodass eine zweite Abfrage desselben fehlenden Objekts erneut anfragt. Die REST-Fehler werden durchgereicht, etwa NotFound für eine ID, die nicht existiert.

Geplante Events, Stages und Sounds

import logging

import bostie
from bostie import Context, fmt
from bostie.events import ScheduledEventUpdate

log = logging.getLogger("my-bot")
bot = bostie.Bot()


@bot.command
async def planned(ctx: Context) -> None:
    """List this server's scheduled events."""
    lines = [
        f"{event.name} {fmt.timestamp(event.scheduled_start_time, 'R')}"
        for event in bot.cache.guild_scheduled_events(ctx.guild_id or 0)
    ]
    await ctx.respond("\n".join(lines) or "Nothing is planned.", ephemeral=True)


@bot.listen
async def moved(update: ScheduledEventUpdate) -> None:
    old, new = update.old, update.scheduled_event
    if old is not None and old.scheduled_start_time != new.scheduled_start_time:
        log.info("%s moved to %s", new.name, new.scheduled_start_time)

Sie kommen mit jedem Server an und folgen ihren Create-, Update- und Delete-Events, die für geplante Events Intents.GUILD_SCHEDULED_EVENTS und für Soundboard-Sounds Intents.GUILD_EXPRESSIONS brauchen; beide sind in Intents.DEFAULT enthalten. Die Antwort auf bot.request_soundboard_sounds ersetzt die Sounds eines Servers. Ihre Update-Events enthalten old, und SoundboardSoundDelete den entfernten sound.

Start

Ein Shard ist bereit, sobald jeder Server, den sein READY aufgeführt hat, als GuildCreate angekommen ist, oder sobald guild_ready_timeout Sekunden lang, standardmäßig 2, kein Server mehr angekommen ist. Started wartet auf jeden Shard. Ein GuildCreate mit joined=True ist ein Server, dem der Bot gerade hinzugefügt wurde; die anderen sind Server, die verfügbar werden.

large_threshold, 50 bis 250 und standardmäßig 50, ist die Mitgliederzahl, ab der Discord einen Server ohne seine Offline-Mitglieder sendet.

Berechtigungen

cache.permissions(channel_id, member) wendet Discords Regeln an: Der Eigentümer des Servers und Administratoren haben alles; ansonsten zählen die Rolle @everyone, dann die Rollen des Mitglieds, dann die Überschreibungen des Kanals für @everyone, für die Rollen und für das Mitglied; ein Mitglied im Timeout behält nur „Kanal ansehen“ und „Nachrichtenverlauf anzeigen“; ohne „Kanal ansehen“ gibt es gar nichts; ohne „Nachrichten senden“ entfallen die Berechtigungen, die sie voraussetzen; in einem Sprachkanal ohne „Verbinden“ entfallen die Sprachberechtigungen. Ein Thread verwendet die Überschreibungen seines übergeordneten Kanals. Es gibt None zurück, wenn dem Cache der Kanal, der Server oder der übergeordnete Kanal eines Threads fehlt.

Dieselbe Berechnung gibt es auch ohne Cache: bostie.permissions.compute_permissions(guild, member, channel=None, *, parent=None), wobei parent der übergeordnete Kanal eines Threads ist. Dieses Modul enthält außerdem Permissions-Mengen aus Discords Dokumentation: ALL, NONE, TEXT, VOICE und STAGE (die Berechtigungen, die für die jeweilige Kanalart gelten), ELEVATED (die, die Zwei-Faktor-Authentifizierung auf dem Konto des Moderators erfordern, wenn der Server sie verlangt) und TIMED_OUT (die, die ein Mitglied im Timeout behält).

Innerhalb einer Interaktion brauchst du den Cache nicht: ctx.interaction.app_permissions enthält die Berechtigungen des Bots im Kanal, und ctx.member.permissions die des Benutzers.

Rollenhierarchie

Ein Mitglied kann nur Mitglieder kicken, bannen, in den Timeout schicken oder bearbeiten, deren höchste Rolle niedriger ist als die eigene, und Discord zeigt den Namen eines Mitglieds in der Farbe seiner höchsten farbigen Rolle. bostie.permissions hat dafür drei Helfer, die den Guild und die Mitglieder aus dem Cache, einer Interaktion oder REST nehmen:

Helfer Gibt zurück
top_role(guild, member) die höchste Role des Mitglieds, oder None, wenn es nur @everyone hat
display_colors(guild, member) die RoleColors seiner höchsten Rolle mit Farbe, oder None
can_act_on(guild, actor, target) ob actor über target steht, einem Mitglied oder einer Rolle: Der Eigentümer steht über allen, niemand steht über dem Eigentümer, und ansonsten wird die höchste Rolle des Akteurs nach Position mit der höchsten Rolle des Ziels verglichen, oder mit der Rolle selbst, so wie Discord es bei der Rollenverwaltung tut

Rollen auf derselben Position werden nach ID geordnet, die ältere zuerst, so wie Discord sie sortiert.

import bostie
from bostie import Context, Embed, Permissions, ResolvedMember
from bostie.permissions import can_act_on, display_colors, top_role

bot = bostie.Bot()


@bot.command(default_permissions=Permissions.KICK_MEMBERS)
async def kick(ctx: Context, member: ResolvedMember) -> None:
    """Kick a member whom both you and the bot outrank."""
    guild = bot.cache.guild(ctx.guild_id or 0)
    me = bot.cache.me(ctx.guild_id or 0)
    if guild is None or me is None or ctx.member is None:
        await ctx.respond("Use this in a server I am in.", ephemeral=True)
        return
    if not (can_act_on(guild, ctx.member, member) and can_act_on(guild, me, member)):
        await ctx.respond(f"{member.mention} ranks too high.", ephemeral=True)
        return
    await bot.rest.members.remove(
        guild.id, member.id, reason=f"/kick by {ctx.user.username}"
    )
    await ctx.respond(f"Kicked {member.display_name}.", ephemeral=True)


@bot.command
async def rank(ctx: Context, member: ResolvedMember) -> None:
    """Show a member's highest role in its color."""
    guild = bot.cache.guild(ctx.guild_id or 0)
    if guild is None:
        await ctx.respond("Use this in a server I am in.", ephemeral=True)
        return
    role = top_role(guild, member)
    colors = display_colors(guild, member)
    embed = Embed(
        description=f"{member.mention}: {role.mention if role else '@everyone'}",
        color=colors.primary_color if colors else None,
    )
    await ctx.respond(embeds=[embed], ephemeral=True)