banterbotapi 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {banterbotapi-0.2.1 → banterbotapi-0.2.3}/PKG-INFO +1 -1
  2. banterbotapi-0.2.3/banterapi/__init__.py +69 -0
  3. banterbotapi-0.2.3/banterapi/client.py +561 -0
  4. banterbotapi-0.2.3/banterapi/commands.py +513 -0
  5. banterbotapi-0.2.3/banterapi/errors.py +99 -0
  6. banterbotapi-0.2.3/banterapi/gateway.py +239 -0
  7. banterbotapi-0.2.3/banterapi/http.py +544 -0
  8. banterbotapi-0.2.3/banterapi/interactions.py +161 -0
  9. banterbotapi-0.2.3/banterapi/models.py +417 -0
  10. {banterbotapi-0.2.1 → banterbotapi-0.2.3}/banterbotapi.egg-info/PKG-INFO +1 -1
  11. {banterbotapi-0.2.1 → banterbotapi-0.2.3}/banterbotapi.egg-info/SOURCES.txt +1 -0
  12. {banterbotapi-0.2.1 → banterbotapi-0.2.3}/pyproject.toml +1 -1
  13. banterbotapi-0.2.1/banterapi/__init__.py +0 -32
  14. banterbotapi-0.2.1/banterapi/client.py +0 -230
  15. banterbotapi-0.2.1/banterapi/commands.py +0 -181
  16. banterbotapi-0.2.1/banterapi/errors.py +0 -39
  17. banterbotapi-0.2.1/banterapi/gateway.py +0 -134
  18. banterbotapi-0.2.1/banterapi/http.py +0 -124
  19. banterbotapi-0.2.1/banterapi/models.py +0 -172
  20. {banterbotapi-0.2.1 → banterbotapi-0.2.3}/LICENSE +0 -0
  21. {banterbotapi-0.2.1 → banterbotapi-0.2.3}/README.md +0 -0
  22. {banterbotapi-0.2.1 → banterbotapi-0.2.3}/banterapi/embed.py +0 -0
  23. {banterbotapi-0.2.1 → banterbotapi-0.2.3}/banterapi/intents.py +0 -0
  24. {banterbotapi-0.2.1 → banterbotapi-0.2.3}/banterapi/permissions.py +0 -0
  25. {banterbotapi-0.2.1 → banterbotapi-0.2.3}/banterbotapi.egg-info/dependency_links.txt +0 -0
  26. {banterbotapi-0.2.1 → banterbotapi-0.2.3}/banterbotapi.egg-info/requires.txt +0 -0
  27. {banterbotapi-0.2.1 → banterbotapi-0.2.3}/banterbotapi.egg-info/top_level.txt +0 -0
  28. {banterbotapi-0.2.1 → banterbotapi-0.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: banterbotapi
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Python SDK for building bots on Banter (banterchat.org) — discord.py-style.
5
5
  Author-email: Banter <contact@banterchat.org>
6
6
  License-Expression: MIT
@@ -0,0 +1,69 @@
1
+ """banterapi — Python SDK for BanterChat bots.
2
+
3
+ Quickstart:
4
+
5
+ import banterapi
6
+
7
+ bot = banterapi.Bot(
8
+ intents=banterapi.Intents.default() | banterapi.Intents.MESSAGE_CONTENT,
9
+ command_prefix="!",
10
+ )
11
+
12
+ @bot.event
13
+ async def on_ready():
14
+ print(f"logged in as {bot.user.username}")
15
+
16
+ @bot.command(slash=True, description="Ping the bot")
17
+ async def ping(ctx):
18
+ await ctx.reply("pong")
19
+
20
+ bot.run("YOUR_BOT_TOKEN")
21
+
22
+ The library mirrors discord.py conventions where possible. See the Bot,
23
+ Intents, Embed, and Permissions classes for the main entry points.
24
+ """
25
+
26
+ __version__ = "0.2.3"
27
+
28
+ from .client import Bot
29
+ from .intents import Intents
30
+ from .embed import Embed
31
+ from .models import User, Guild, Channel, Member, Role, Message
32
+ from .errors import BanterError, HTTPException, Forbidden, NotFound, RateLimited, GatewayError, LoginFailure, MissingPermissions
33
+ from .permissions import Permissions
34
+ from .commands import (
35
+ has_permissions,
36
+ SlashOption,
37
+ OPTION_STRING, OPTION_INTEGER, OPTION_BOOLEAN,
38
+ OPTION_USER, OPTION_CHANNEL, OPTION_ROLE,
39
+ )
40
+ from .interactions import Interaction
41
+
42
+ __all__ = [
43
+ "__version__",
44
+ "Bot",
45
+ "Intents",
46
+ "Embed",
47
+ "User",
48
+ "Guild",
49
+ "Channel",
50
+ "Member",
51
+ "Role",
52
+ "Message",
53
+ "Permissions",
54
+ "has_permissions",
55
+ # Slash command support
56
+ "SlashOption",
57
+ "OPTION_STRING", "OPTION_INTEGER", "OPTION_BOOLEAN",
58
+ "OPTION_USER", "OPTION_CHANNEL", "OPTION_ROLE",
59
+ "Interaction",
60
+ # Errors
61
+ "BanterError",
62
+ "HTTPException",
63
+ "Forbidden",
64
+ "NotFound",
65
+ "RateLimited",
66
+ "GatewayError",
67
+ "LoginFailure",
68
+ "MissingPermissions",
69
+ ]
@@ -0,0 +1,561 @@
1
+ """High-level bot client — the main entry point of the SDK.
2
+
3
+ :class:`Bot` wires everything together:
4
+
5
+ * :class:`~banterapi.http.HTTPClient` for REST calls.
6
+ * :class:`~banterapi.gateway.Gateway` for the WebSocket connection.
7
+ * :class:`~banterapi.commands.CommandRegistry` for prefix/slash commands.
8
+ * An event-handler map (``on_ready``, ``on_message``, ...) populated
9
+ via the :meth:`Bot.event` decorator.
10
+
11
+ Typical usage::
12
+
13
+ bot = Bot(intents=Intents.default() | Intents.MESSAGE_CONTENT)
14
+
15
+ @bot.event
16
+ async def on_ready():
17
+ print("logged in")
18
+
19
+ @bot.command(slash=True, description="Say pong")
20
+ async def ping(ctx):
21
+ await ctx.reply("pong")
22
+
23
+ bot.run(TOKEN)
24
+
25
+ :meth:`Bot.run` blocks until the bot disconnects (or ``KeyboardInterrupt``).
26
+ It manages the reconnect loop internally when ``reconnect=True``.
27
+ """
28
+
29
+ import asyncio
30
+ import inspect
31
+ import logging
32
+
33
+ from .commands import Command, CommandRegistry, Context, CommandError, CommandNotFound
34
+ from .gateway import Gateway
35
+ from .http import HTTPClient
36
+ from .intents import Intents
37
+ from .models import User, Guild, Channel, Member, Message
38
+ from .errors import GatewayError
39
+
40
+ log = logging.getLogger("banterapi.client")
41
+
42
+
43
+ # Default REST/gateway URL fragments. Kept as module constants so that
44
+ # the URL derivation in __init__ is easy to trace and future API
45
+ # version bumps only touch these lines.
46
+ _DEFAULT_BASE_URL = "https://banterchat.org"
47
+ _GATEWAY_PATH = "/api/v1/bot/gateway"
48
+
49
+
50
+ class Bot:
51
+ """Main bot entry point.
52
+
53
+ Args:
54
+ intents: :class:`~banterapi.Intents` bitmask controlling which
55
+ gateway events the server will send. Defaults to
56
+ :meth:`Intents.default`. If you register a prefix command
57
+ you almost certainly need
58
+ ``Intents.default() | Intents.MESSAGE_CONTENT``.
59
+ base_url: HTTP origin (scheme + host) of the Banter instance.
60
+ Defaults to ``https://banterchat.org``. Trailing slash is
61
+ stripped.
62
+ ws_url: Override for the gateway URL. If ``None`` (default), the
63
+ SDK derives it from ``base_url`` by switching the scheme
64
+ (``http`` → ``ws``, ``https`` → ``wss``) and appending the
65
+ gateway path. Set this only for local/testing setups where
66
+ REST and gateway live on different hosts.
67
+ reconnect: If ``True`` (default), :meth:`run` sleeps 5s and
68
+ reconnects on any transient gateway error. Set ``False``
69
+ to have :meth:`run` return on the first disconnect.
70
+ command_prefix: Prefix for text commands (``!`` by default).
71
+ Messages beginning with this prefix are parsed and routed
72
+ to registered commands.
73
+ application_id: Your bot application's ID, used for slash
74
+ command registration. If empty, the SDK reads it off the
75
+ READY frame the gateway sends on connect — you usually
76
+ don't need to pass this explicitly.
77
+ help_enabled: Register a default ``help`` command that lists
78
+ registered commands in an embed. ``True`` by default.
79
+ Disable only if you're providing a custom help command of
80
+ your own; most users should leave this on.
81
+ help_color: RGB int (``0xRRGGBB``) used as the embed accent for
82
+ the default help command. Only applies when
83
+ ``help_enabled=True``.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ intents=None,
89
+ *,
90
+ base_url=_DEFAULT_BASE_URL,
91
+ ws_url=None,
92
+ reconnect=True,
93
+ command_prefix="!",
94
+ application_id="",
95
+ help_enabled=True,
96
+ help_color=0x5865f2,
97
+ ):
98
+ self.intents = intents if intents is not None else Intents.default()
99
+ self.base_url = base_url.rstrip("/")
100
+ # Derive gateway URL from base_url unless explicitly overridden.
101
+ # http: → ws:, https: → wss:. The path segment lives as a module
102
+ # constant so /api/v1/bot/* migration is a single-line edit.
103
+ self.ws_url = ws_url or (
104
+ self.base_url
105
+ .replace("http://", "ws://")
106
+ .replace("https://", "wss://")
107
+ + _GATEWAY_PATH
108
+ )
109
+ self.reconnect = reconnect
110
+ self.command_prefix = command_prefix
111
+ self.application_id = application_id
112
+ self.help_enabled = help_enabled
113
+ self.help_color = help_color
114
+ self.user = None
115
+ self.guilds = {}
116
+ self._http = None
117
+ self._gateway = None
118
+ self._handlers = {}
119
+ self._commands = CommandRegistry()
120
+ # _slash_commands stores registration metadata sent to the server
121
+ # at sync_commands() time: [{name, description, options}, ...].
122
+ # _slash_handlers maps command-name → coroutine invoked when the
123
+ # gateway delivers an interaction_create event for that command.
124
+ # Separate from _commands because slash handlers take an
125
+ # Interaction, not a (Context, *args) like prefix handlers.
126
+ self._slash_commands = []
127
+ self._slash_handlers = {}
128
+ self._loop = None
129
+ if self.help_enabled:
130
+ self._register_default_help()
131
+
132
+ def event(self, fn):
133
+ """Register ``fn`` as an event handler.
134
+
135
+ The handler's name must begin with ``on_`` and is looked up
136
+ against event types the gateway dispatches (``on_ready``,
137
+ ``on_message``, ``on_reaction_add``, ``on_member_join``, …). One
138
+ handler per event name — re-decorating with the same name
139
+ overwrites the previous handler.
140
+
141
+ Handlers may be sync or async. Exceptions raised inside a
142
+ handler are logged and swallowed so one bad handler can't take
143
+ the bot down.
144
+
145
+ Usage::
146
+
147
+ @bot.event
148
+ async def on_ready():
149
+ print("ready")
150
+
151
+ Raises:
152
+ ValueError: Handler name does not start with ``on_``.
153
+ """
154
+ name = fn.__name__
155
+ if not name.startswith("on_"):
156
+ raise ValueError("event handlers must be named on_<event>")
157
+ self._handlers[name] = fn
158
+ return fn
159
+
160
+ def command(self, name=None, aliases=None, help=None, slash=False, description="", options=None):
161
+ """Register a prefix command (optionally also as a slash command).
162
+
163
+ The decorated function is called as ``await fn(ctx, *args)`` —
164
+ positional arguments are parsed from the message text after the
165
+ prefix. Argument annotations (``int``, ``float``, ``bool``) are
166
+ applied automatically by :class:`~banterapi.commands.Command`;
167
+ ``*args`` and keyword-only (``*, text: str``) signatures are
168
+ supported.
169
+
170
+ Args:
171
+ name: Command name as typed (without prefix). Defaults to
172
+ the function name.
173
+ aliases: Iterable of additional names the command answers
174
+ to (e.g. ``aliases=["p"]`` for a ``ping`` command).
175
+ help: Short help text shown in the default ``help`` embed.
176
+ Defaults to the function's docstring's first line.
177
+ slash: If ``True``, also register as a slash command at
178
+ :meth:`sync_commands` time. The user sees it in the
179
+ ``/`` autocomplete dropdown.
180
+ description: Slash-command description. Ignored when
181
+ ``slash=False``. Falls back to ``help`` or the name.
182
+ options: Iterable of :class:`~banterapi.SlashOption` to
183
+ expose as typed slash-command arguments. Only
184
+ applies when ``slash=True``. When a user invokes the
185
+ slash command, the server parses the typed options
186
+ and delivers them to the slash handler as
187
+ ``interaction.options``. Prefix-invocation is
188
+ unaffected — prefix args continue to parse from the
189
+ message text via function-signature introspection.
190
+
191
+ Usage::
192
+
193
+ @bot.command(aliases=["p"], slash=True, description="Health check")
194
+ async def ping(ctx):
195
+ await ctx.reply("pong")
196
+
197
+ With typed slash options::
198
+
199
+ from banterapi import SlashOption, OPTION_INTEGER
200
+
201
+ @bot.command(
202
+ slash=True, description="Roll a die",
203
+ options=[SlashOption("sides", type=OPTION_INTEGER, required=False)],
204
+ )
205
+ async def roll(ctx, sides: int = 6):
206
+ await ctx.reply(f"🎲 {sides}")
207
+ """
208
+ def decorator(fn):
209
+ cmd = Command(fn, name=name, aliases=aliases, help=help)
210
+ self._commands.add(cmd)
211
+ if slash:
212
+ entry = {
213
+ "name": cmd.name,
214
+ "description": description or cmd.help or cmd.name,
215
+ }
216
+ if options:
217
+ # Defer import to avoid a circular: commands.py is
218
+ # loaded by client.py, and SlashOption lives there.
219
+ from .commands import SlashOption as _SO
220
+ entry["options"] = [
221
+ (o.to_dict() if isinstance(o, _SO) else dict(o))
222
+ for o in options
223
+ ]
224
+ self._slash_commands.append(entry)
225
+ return fn
226
+ return decorator
227
+
228
+ def slash_command(self, name=None, description="", options=None):
229
+ """Register a standalone slash command (no prefix twin).
230
+
231
+ The decorated function takes a single argument — an
232
+ :class:`~banterapi.interactions.Interaction` — and responds
233
+ via ``await interaction.respond(...)``. This differs from
234
+ :meth:`command` (prefix commands) which takes a
235
+ :class:`~banterapi.commands.Context` and parses args from
236
+ message text.
237
+
238
+ Use this when the command has structured typed args and makes
239
+ no sense as a plain-text prefix command, or when you want the
240
+ command only accessible through the UI autocomplete.
241
+
242
+ Args:
243
+ name: Slash command name. Defaults to the function name.
244
+ description: Shown in the autocomplete dropdown.
245
+ options: Iterable of :class:`~banterapi.SlashOption`.
246
+
247
+ Usage::
248
+
249
+ from banterapi import SlashOption, OPTION_STRING, OPTION_INTEGER
250
+
251
+ @bot.slash_command(
252
+ name="greet",
253
+ description="Greet a user with a custom message",
254
+ options=[
255
+ SlashOption("name", type=OPTION_STRING),
256
+ SlashOption("times", type=OPTION_INTEGER, required=False),
257
+ ],
258
+ )
259
+ async def greet(interaction):
260
+ n = interaction.options["name"]
261
+ k = interaction.options.get("times", 1)
262
+ await interaction.respond(f"Hello {n}! " * k)
263
+ """
264
+ def decorator(fn):
265
+ cmd_name = name or fn.__name__
266
+ entry = {
267
+ "name": cmd_name,
268
+ "description": description or cmd_name,
269
+ }
270
+ if options:
271
+ from .commands import SlashOption as _SO
272
+ entry["options"] = [
273
+ (o.to_dict() if isinstance(o, _SO) else dict(o))
274
+ for o in options
275
+ ]
276
+ self._slash_commands.append(entry)
277
+ self._slash_handlers[cmd_name] = fn
278
+ return fn
279
+ return decorator
280
+
281
+ def _register_default_help(self):
282
+ from .embed import Embed
283
+
284
+ @self.command(name="help", slash=True, description="Show available commands")
285
+ async def _help(ctx):
286
+ visible = [c for c in self._commands.all() if not getattr(c, "_hidden", False)]
287
+ visible.sort(key=lambda c: c.name)
288
+ e = Embed(
289
+ title=f"{self.user.username if self.user else 'Bot'} — Commands",
290
+ description=f"Prefix: `{self.command_prefix}` · {len(visible)} command(s)",
291
+ color=self.help_color,
292
+ )
293
+ lines = []
294
+ for cmd in visible:
295
+ aliases = f" · aliases: {', '.join(self.command_prefix + a for a in cmd.aliases)}" if cmd.aliases else ""
296
+ doc = (cmd.help or "").strip().split("\n", 1)[0]
297
+ lines.append(f"`{self.command_prefix}{cmd.name}`{aliases} — {doc}" if doc else f"`{self.command_prefix}{cmd.name}`{aliases}")
298
+ if lines:
299
+ chunk, chunks = "", []
300
+ for line in lines:
301
+ if len(chunk) + len(line) + 1 > 1000:
302
+ chunks.append(chunk)
303
+ chunk = line
304
+ else:
305
+ chunk = f"{chunk}\n{line}" if chunk else line
306
+ if chunk:
307
+ chunks.append(chunk)
308
+ for i, c in enumerate(chunks):
309
+ e.add_field("Commands" if i == 0 else "\u200b", c, inline=False)
310
+ e.set_footer(f"Type {self.command_prefix}<command> or /<command>")
311
+ await ctx.reply(embed=e)
312
+
313
+ async def sync_commands(self):
314
+ """Push the bot's slash-command definitions to the server.
315
+
316
+ Called automatically on ``on_ready`` when any commands are
317
+ registered with ``slash=True``. Safe to call manually too — it
318
+ replaces the server's stored command list for this bot, so
319
+ calling it a second time with a shorter list deletes any
320
+ commands no longer present.
321
+
322
+ The target application is resolved server-side from the bot
323
+ token (``PUT /api/v1/bot/applications/@me/commands``) — the
324
+ ``application_id`` attribute on :class:`Bot` is populated by
325
+ the READY frame for reference but isn't used in this call.
326
+
327
+ Returns:
328
+ int: Number of commands the server acknowledged. Zero if
329
+ no slash commands are registered locally.
330
+ """
331
+ if not self._slash_commands:
332
+ return 0
333
+ result = await self._http.register_commands(self._slash_commands)
334
+ return result.get("registered", len(self._slash_commands)) if result else 0
335
+
336
+ @property
337
+ def commands(self):
338
+ return self._commands.all()
339
+
340
+ async def process_commands(self, message):
341
+ if self.user is not None and message.user_id == self.user.id:
342
+ return
343
+ content = message.content or ""
344
+ if not content.startswith(self.command_prefix):
345
+ return
346
+ stripped = content[len(self.command_prefix):]
347
+ if not stripped:
348
+ return
349
+ parts = stripped.split(maxsplit=1)
350
+ name = parts[0]
351
+ args_raw = parts[1] if len(parts) > 1 else ""
352
+ cmd = self._commands.get(name)
353
+ if cmd is None:
354
+ return
355
+ ctx = Context(self, message, name, args_raw)
356
+ try:
357
+ await cmd.invoke(ctx)
358
+ except CommandError as e:
359
+ await self._fire("on_command_error", ctx, e)
360
+ except Exception as e:
361
+ await self._fire("on_command_error", ctx, e)
362
+
363
+ async def _dispatch(self, event_type, payload):
364
+ if event_type == "ready":
365
+ user_data = payload.get("user", {})
366
+ self.user = User.from_dict(user_data)
367
+ for g in payload.get("guilds", []) or []:
368
+ guild = Guild.from_dict(g)
369
+ self.guilds[guild.id] = guild
370
+ app_id_from_ready = payload.get("application_id", "")
371
+ if app_id_from_ready and not self.application_id:
372
+ self.application_id = app_id_from_ready
373
+ if self._slash_commands:
374
+ try:
375
+ await self.sync_commands()
376
+ except Exception as e:
377
+ log.warning("command sync failed: %s", e)
378
+ await self._fire("on_ready")
379
+ return
380
+
381
+ if event_type in ("channel_message", "message_create"):
382
+ msg = Message.from_dict(payload, client=self)
383
+ await self._fire("on_message", msg)
384
+ await self.process_commands(msg)
385
+ return
386
+
387
+ if event_type == "message_edit":
388
+ await self._fire("on_message_edit", payload)
389
+ return
390
+
391
+ if event_type == "message_delete":
392
+ await self._fire("on_message_delete", payload)
393
+ return
394
+
395
+ if event_type == "reaction_add":
396
+ await self._fire("on_reaction_add", payload)
397
+ return
398
+
399
+ if event_type == "reaction_remove":
400
+ await self._fire("on_reaction_remove", payload)
401
+ return
402
+
403
+ if event_type == "guild_member_add":
404
+ await self._fire("on_member_join", payload)
405
+ return
406
+
407
+ if event_type == "guild_member_remove":
408
+ await self._fire("on_member_remove", payload)
409
+ return
410
+
411
+ if event_type == "error":
412
+ await self._fire("on_error", payload)
413
+ return
414
+
415
+ if event_type == "interaction_create":
416
+ # Route slash-command invocations to their registered
417
+ # handlers. Falls back to a no-op (plus on_raw_event) if
418
+ # the command name isn't known — that shouldn't normally
419
+ # happen because the server filters by command ownership,
420
+ # but we don't want to crash the dispatch loop on drift.
421
+ from .interactions import Interaction
422
+ interaction = Interaction(payload, client=self)
423
+ handler = self._slash_handlers.get(interaction.command_name)
424
+ if handler is not None:
425
+ try:
426
+ result = handler(interaction)
427
+ if inspect.isawaitable(result):
428
+ await result
429
+ except Exception:
430
+ log.exception("slash handler %r raised", interaction.command_name)
431
+ else:
432
+ log.warning(
433
+ "received interaction for unknown slash command %r",
434
+ interaction.command_name,
435
+ )
436
+ await self._fire("on_interaction", interaction)
437
+ return
438
+
439
+ await self._fire("on_raw_event", event_type, payload)
440
+
441
+ async def _fire(self, name, *args):
442
+ handler = self._handlers.get(name)
443
+ if handler is None:
444
+ return
445
+ try:
446
+ result = handler(*args)
447
+ if inspect.isawaitable(result):
448
+ await result
449
+ except Exception:
450
+ log.exception("handler %s raised", name)
451
+
452
+ async def send_message(self, channel_id, content="", embed=None, reply_to=""):
453
+ d = await self._http.send_message(channel_id, content=content, embed=embed, reply_to=reply_to)
454
+ return Message.from_dict(d, client=self) if d else None
455
+
456
+ async def get_user(self, user_id):
457
+ d = await self._http.get_user(user_id)
458
+ return User.from_dict(d) if d else None
459
+
460
+ async def get_guild(self, guild_id):
461
+ """Fetch a guild by ID. Returns a :class:`Guild` or ``None``."""
462
+ d = await self._http.get_guild(guild_id)
463
+ return Guild.from_dict(d) if d else None
464
+
465
+ async def get_member(self, guild_id, user_id):
466
+ """Fetch a guild member by user ID. Returns :class:`Member` or ``None``."""
467
+ d = await self._http.get_member(guild_id, user_id)
468
+ return Member.from_dict(d, guild_id=guild_id) if d else None
469
+
470
+ async def list_channels(self, guild_id):
471
+ """List channels in a guild the bot can see. Returns [Channel, ...]."""
472
+ d = await self._http.list_channels(guild_id)
473
+ return [Channel.from_dict(c, client=self) for c in (d or [])]
474
+
475
+ async def get_channel(self, channel_id):
476
+ """Fetch a single channel by ID. Returns :class:`Channel` or ``None``.
477
+
478
+ Use this when you have only a channel_id (e.g. from a message
479
+ event) and want the full Channel record with its name and
480
+ description, not just the stub from ``msg.channel``.
481
+ """
482
+ d = await self._http.get_channel(channel_id)
483
+ return Channel.from_dict(d, client=self) if d else None
484
+
485
+ async def list_roles(self, guild_id):
486
+ """List roles in a guild. Returns [Role, ...]."""
487
+ from .models import Role
488
+ d = await self._http.list_roles(guild_id)
489
+ return [Role.from_dict(r, client=self) for r in (d or [])]
490
+
491
+ async def create_channel(self, guild_id, name, **kwargs):
492
+ """Create a channel. Requires MANAGE_CHANNELS in the guild.
493
+
494
+ Keyword args are forwarded to
495
+ :meth:`HTTPClient.create_channel` (``description``,
496
+ ``category_id``, ``type``). Returns the created
497
+ :class:`Channel` or ``None``.
498
+ """
499
+ d = await self._http.create_channel(guild_id, name, **kwargs)
500
+ return Channel.from_dict(d, client=self) if d else None
501
+
502
+ async def create_role(self, guild_id, name, **kwargs):
503
+ """Create a role. Requires MANAGE_ROLES in the guild.
504
+
505
+ Keyword args are forwarded to :meth:`HTTPClient.create_role`
506
+ (``color``, ``permissions``, ...). Returns the created
507
+ :class:`Role` or ``None``.
508
+ """
509
+ from .models import Role
510
+ d = await self._http.create_role(guild_id, name, **kwargs)
511
+ return Role.from_dict(d, client=self) if d else None
512
+
513
+ async def _start(self, token):
514
+ """Async entry point driving the connect → run → reconnect loop.
515
+
516
+ Runs until cancelled (Ctrl-C from :meth:`run`) or until a
517
+ non-transient error breaks out. :class:`LoginFailure` from the
518
+ gateway connect step is NOT retried — the token is bad, no
519
+ amount of reconnecting will help.
520
+
521
+ Transient :class:`~banterapi.errors.GatewayError` failures are
522
+ logged and followed by a 5-second sleep before reconnecting
523
+ (when :attr:`reconnect` is truthy). The reconnect delay is
524
+ fixed, not exponential — good enough for a dev-scale bot; a
525
+ production client should layer backoff around this if it's
526
+ reconnecting dozens of times a day.
527
+ """
528
+ self._http = HTTPClient(token, base_url=self.base_url)
529
+ self._gateway = Gateway(token, self.intents, self.ws_url, self._dispatch)
530
+ try:
531
+ while True:
532
+ try:
533
+ await self._gateway.connect()
534
+ await self._gateway.run()
535
+ except GatewayError as e:
536
+ log.warning("gateway error: %s", e)
537
+ if not self.reconnect:
538
+ break
539
+ log.info("reconnecting in 5s")
540
+ await asyncio.sleep(5)
541
+ finally:
542
+ await self._gateway.close()
543
+ await self._http.close()
544
+
545
+ def run(self, token):
546
+ """Blocking entry point. Start the bot and run until disconnected.
547
+
548
+ This is the synchronous façade most scripts use:
549
+ ``bot.run(TOKEN)`` at the bottom of a file. Internally it spins
550
+ up an asyncio event loop and drives :meth:`_start`.
551
+
552
+ Ctrl-C (``KeyboardInterrupt``) is caught and swallowed on
553
+ purpose — it's the standard shutdown signal for a CLI-launched
554
+ bot, and rethrowing would produce a noisy traceback the user
555
+ can't act on. Non-``KeyboardInterrupt`` exceptions still
556
+ propagate.
557
+ """
558
+ try:
559
+ asyncio.run(self._start(token))
560
+ except KeyboardInterrupt:
561
+ pass