Saltar a contenido

Caché y permisos

Bostie deja caer un hueso en una caja

bot.cache guarda lo que el gateway le contó al bot, para que los handlers puedan buscar cosas sin hacer una solicitud. Se llena a partir de los eventos, nunca de las llamadas REST que haces.

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)

Qué guarda

Se guarda Por defecto Opción
los servidores, con sus roles, emojis y stickers sí
los canales y los hilos activos sí
los estados de voz sí
los eventos programados, las instancias de escenario y los sonidos del panel de sonidos sí
el usuario del bot y su propio miembro en cada servidor sí
todos los miembros no members=True, necesita Intents.GUILD_MEMBERS
las presencias de los miembros que no están desconectados no presences=True, necesita Intents.GUILD_PRESENCES
los mensajes más recientes no max_messages=N
los canales de MD más recientes no max_dm_channels=N

Bot(cache=False) no guarda nada, y entonces las demás opciones lanzan ValueError. Una opción cuyo intent falta lanza MissingIntent cuando se crea el Bot. Los canales se guardan tal como los envió Discord, salvo los campos que CHANNEL_UPDATE no mantiene actualizados (last_message_id, message_count, member_count, last_pin_timestamp), que son None.

Con members=True, Discord envía solo algunos miembros de los servidores grandes cuando el bot se conecta. bostie pide entonces el resto, un servidor a la vez por shard, y un shard cuenta como listo solo cuando sus servidores están completos. chunk_members=False omite eso, y la caché se va llenando a medida que los miembros se unen, cambian o hablan. Las solicitudes que reciben un rate limit se reintentan después del retraso que indica Discord.

Búsquedas

Método Devuelve
guild(id), guilds() un Guild o todos
channel(id), guild_channels(guild_id) un Channel, o los canales de un servidor con sus hilos activos
role(id), guild_roles(guild_id) un Role, o los roles de un servidor
voice_state(guild_id, user_id), guild_voice_states(guild_id) dónde están los usuarios en voz; None cuando el usuario no está en ningún canal de voz
scheduled_event(id), guild_scheduled_events(guild_id) un ScheduledEvent, o los de un servidor
stage_instance(id), guild_stage_instances(guild_id) una StageInstance en vivo, o las de un servidor
soundboard_sound(sound_id), guild_soundboard_sounds(guild_id) un SoundboardSound, o los de un servidor
me(guild_id) el propio Member del bot en un servidor
member(guild_id, user_id), guild_members(guild_id) miembros; solo el del propio bot, salvo que members=True
presence(guild_id, user_id), guild_presences(guild_id) presencias, con presences=True; None si el usuario está desconectado
user(id) el usuario del bot, o el usuario de un miembro o canal de MD en caché
dm_channel(user_id) el canal de MD del bot con un usuario, si se guarda
message(id) uno de los max_messages mensajes más recientes
permissions(channel_id, member=None) los permisos de un miembro en un canal, los del bot si no se indica member
guild_permissions(guild_id, member=None) lo mismo, en todo el servidor

Las búsquedas devuelven None, o una lista vacía, para lo que la caché no contiene. bot.cache.clear() la vacía. Los eventos de actualización y de eliminación traen el objeto en caché de antes del cambio, como explica Eventos.

La caché o, si no, 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 devuelve un objeto de la caché cuando lo tiene, y si no, se lo pide a REST: user(user_id), member(guild_id, user_id), channel(channel_id), guild(guild_id), role(guild_id, role_id) y message(channel_id, message_id). Lo que devuelve REST no se guarda: la caché sigue atendiendo solo al gateway, así que una segunda búsqueda del mismo objeto ausente vuelve a preguntar. Los errores de REST pasan tal cual, como NotFound para un id que no existe.

Eventos programados, escenarios y sonidos

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)

Llegan con cada servidor y siguen sus eventos de creación, actualización y eliminación, que necesitan Intents.GUILD_SCHEDULED_EVENTS para los eventos programados y Intents.GUILD_EXPRESSIONS para los sonidos del panel de sonidos; los dos están en Intents.DEFAULT. La respuesta a bot.request_soundboard_sounds reemplaza los sonidos de un servidor. Sus eventos de actualización traen old, y SoundboardSoundDelete trae el sound eliminado.

Arranque

Un shard está listo cuando cada servidor que enumeró su READY llegó como un GuildCreate, o cuando no llegó ningún servidor durante guild_ready_timeout segundos, 2 por defecto. Started espera a todos los shards. Un GuildCreate con joined=True es un servidor al que se acaba de agregar el bot; los demás son servidores que pasan a estar disponibles.

large_threshold, de 50 a 250 y 50 por defecto, es la cantidad de miembros a partir de la cual Discord envía un servidor sin sus miembros desconectados.

Permisos

cache.permissions(channel_id, member) aplica las reglas de Discord: el propietario del servidor y los administradores tienen todo; si no, el rol @everyone, luego los roles del miembro y luego las sobrescrituras del canal para @everyone, para los roles y para el miembro; un miembro aislado temporalmente conserva solo Ver canal y Leer el historial de mensajes; sin Ver canal no hay nada; sin Enviar mensajes desaparecen los permisos que lo necesitan; en un canal de voz sin Conectar, desaparecen los permisos de voz. Un hilo usa las sobrescrituras de su canal padre. Devuelve None cuando a la caché le falta el canal, el servidor o el canal padre de un hilo.

El mismo cálculo está disponible sin la caché: bostie.permissions.compute_permissions(guild, member, channel=None, *, parent=None), donde parent es el canal padre de un hilo. Ese módulo también contiene conjuntos de Permissions tomados de la documentación de Discord: ALL, NONE, TEXT, VOICE y STAGE (los permisos que se aplican a cada tipo de canal), ELEVATED (los que necesitan la autenticación en dos pasos en la cuenta del moderador cuando el servidor la exige) y TIMED_OUT (los que conserva un miembro aislado temporalmente).

Dentro de una interacción no hace falta la caché: ctx.interaction.app_permissions contiene los permisos del bot en el canal, y ctx.member.permissions, los del usuario.

Jerarquía de roles

Un miembro solo puede expulsar, banear, aislar temporalmente o editar a los miembros cuyo rol más alto está por debajo del suyo, y Discord muestra el nombre de un miembro con el color de su rol con color más alto. bostie.permissions tiene las tres funciones auxiliares, que aceptan el Guild y los miembros de la caché, de una interacción o de REST:

Función auxiliar Devuelve
top_role(guild, member) el Role más alto del miembro, o None cuando solo tiene @everyone
display_colors(guild, member) los RoleColors de su rol más alto con color, o None
can_act_on(guild, actor, target) si actor está por encima de target, un miembro o un rol: el propietario está por encima de todos, nadie está por encima del propietario y, si no, el rol más alto del actor se compara por posición con el rol más alto del objetivo, o con el rol mismo, como hace Discord para la gestión de roles

Los roles en la misma posición se ordenan por id, primero el más antiguo, como los ordena Discord.

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)