banterbotapi 0.2.1__tar.gz → 0.2.2__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.
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/PKG-INFO +1 -1
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterapi/__init__.py +26 -1
- banterbotapi-0.2.2/banterapi/client.py +460 -0
- banterbotapi-0.2.2/banterapi/commands.py +410 -0
- banterbotapi-0.2.2/banterapi/errors.py +99 -0
- banterbotapi-0.2.2/banterapi/gateway.py +239 -0
- banterbotapi-0.2.2/banterapi/http.py +514 -0
- banterbotapi-0.2.2/banterapi/models.py +417 -0
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterbotapi.egg-info/PKG-INFO +1 -1
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/pyproject.toml +1 -1
- banterbotapi-0.2.1/banterapi/client.py +0 -230
- banterbotapi-0.2.1/banterapi/commands.py +0 -181
- banterbotapi-0.2.1/banterapi/errors.py +0 -39
- banterbotapi-0.2.1/banterapi/gateway.py +0 -134
- banterbotapi-0.2.1/banterapi/http.py +0 -124
- banterbotapi-0.2.1/banterapi/models.py +0 -172
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/LICENSE +0 -0
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/README.md +0 -0
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterapi/embed.py +0 -0
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterapi/intents.py +0 -0
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterapi/permissions.py +0 -0
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterbotapi.egg-info/SOURCES.txt +0 -0
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterbotapi.egg-info/dependency_links.txt +0 -0
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterbotapi.egg-info/requires.txt +0 -0
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterbotapi.egg-info/top_level.txt +0 -0
- {banterbotapi-0.2.1 → banterbotapi-0.2.2}/setup.cfg +0 -0
|
@@ -1,4 +1,29 @@
|
|
|
1
|
-
|
|
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.2"
|
|
2
27
|
|
|
3
28
|
from .client import Bot
|
|
4
29
|
from .intents import Intents
|
|
@@ -0,0 +1,460 @@
|
|
|
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
|
+
self._slash_commands = []
|
|
121
|
+
self._loop = None
|
|
122
|
+
if self.help_enabled:
|
|
123
|
+
self._register_default_help()
|
|
124
|
+
|
|
125
|
+
def event(self, fn):
|
|
126
|
+
"""Register ``fn`` as an event handler.
|
|
127
|
+
|
|
128
|
+
The handler's name must begin with ``on_`` and is looked up
|
|
129
|
+
against event types the gateway dispatches (``on_ready``,
|
|
130
|
+
``on_message``, ``on_reaction_add``, ``on_member_join``, …). One
|
|
131
|
+
handler per event name — re-decorating with the same name
|
|
132
|
+
overwrites the previous handler.
|
|
133
|
+
|
|
134
|
+
Handlers may be sync or async. Exceptions raised inside a
|
|
135
|
+
handler are logged and swallowed so one bad handler can't take
|
|
136
|
+
the bot down.
|
|
137
|
+
|
|
138
|
+
Usage::
|
|
139
|
+
|
|
140
|
+
@bot.event
|
|
141
|
+
async def on_ready():
|
|
142
|
+
print("ready")
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: Handler name does not start with ``on_``.
|
|
146
|
+
"""
|
|
147
|
+
name = fn.__name__
|
|
148
|
+
if not name.startswith("on_"):
|
|
149
|
+
raise ValueError("event handlers must be named on_<event>")
|
|
150
|
+
self._handlers[name] = fn
|
|
151
|
+
return fn
|
|
152
|
+
|
|
153
|
+
def command(self, name=None, aliases=None, help=None, slash=False, description=""):
|
|
154
|
+
"""Register a prefix command (optionally also as a slash command).
|
|
155
|
+
|
|
156
|
+
The decorated function is called as ``await fn(ctx, *args)`` —
|
|
157
|
+
positional arguments are parsed from the message text after the
|
|
158
|
+
prefix. Argument annotations (``int``, ``float``, ``bool``) are
|
|
159
|
+
applied automatically by :class:`~banterapi.commands.Command`;
|
|
160
|
+
``*args`` and keyword-only (``*, text: str``) signatures are
|
|
161
|
+
supported.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
name: Command name as typed (without prefix). Defaults to
|
|
165
|
+
the function name.
|
|
166
|
+
aliases: Iterable of additional names the command answers
|
|
167
|
+
to (e.g. ``aliases=["p"]`` for a ``ping`` command).
|
|
168
|
+
help: Short help text shown in the default ``help`` embed.
|
|
169
|
+
Defaults to the function's docstring's first line.
|
|
170
|
+
slash: If ``True``, also register as a slash command at
|
|
171
|
+
:meth:`sync_commands` time. The user sees it in the
|
|
172
|
+
``/`` autocomplete dropdown.
|
|
173
|
+
description: Slash-command description. Ignored when
|
|
174
|
+
``slash=False``. Falls back to ``help`` or the name.
|
|
175
|
+
|
|
176
|
+
Usage::
|
|
177
|
+
|
|
178
|
+
@bot.command(aliases=["p"], slash=True, description="Health check")
|
|
179
|
+
async def ping(ctx):
|
|
180
|
+
await ctx.reply("pong")
|
|
181
|
+
"""
|
|
182
|
+
def decorator(fn):
|
|
183
|
+
cmd = Command(fn, name=name, aliases=aliases, help=help)
|
|
184
|
+
self._commands.add(cmd)
|
|
185
|
+
if slash:
|
|
186
|
+
self._slash_commands.append({
|
|
187
|
+
"name": cmd.name,
|
|
188
|
+
"description": description or cmd.help or cmd.name,
|
|
189
|
+
})
|
|
190
|
+
return fn
|
|
191
|
+
return decorator
|
|
192
|
+
|
|
193
|
+
def slash_command(self, name=None, description=""):
|
|
194
|
+
"""Shortcut for :meth:`command` with ``slash=True``.
|
|
195
|
+
|
|
196
|
+
Use this when you want a command to exist *only* as a slash
|
|
197
|
+
command (no prefix invocation). The underlying registration is
|
|
198
|
+
identical; the command is still callable by its registered
|
|
199
|
+
name if a user types the prefix, but it won't appear in the
|
|
200
|
+
prefix-command listing by convention.
|
|
201
|
+
"""
|
|
202
|
+
return self.command(name=name, help=description, slash=True, description=description)
|
|
203
|
+
|
|
204
|
+
def _register_default_help(self):
|
|
205
|
+
from .embed import Embed
|
|
206
|
+
|
|
207
|
+
@self.command(name="help", slash=True, description="Show available commands")
|
|
208
|
+
async def _help(ctx):
|
|
209
|
+
visible = [c for c in self._commands.all() if not getattr(c, "_hidden", False)]
|
|
210
|
+
visible.sort(key=lambda c: c.name)
|
|
211
|
+
e = Embed(
|
|
212
|
+
title=f"{self.user.username if self.user else 'Bot'} — Commands",
|
|
213
|
+
description=f"Prefix: `{self.command_prefix}` · {len(visible)} command(s)",
|
|
214
|
+
color=self.help_color,
|
|
215
|
+
)
|
|
216
|
+
lines = []
|
|
217
|
+
for cmd in visible:
|
|
218
|
+
aliases = f" · aliases: {', '.join(self.command_prefix + a for a in cmd.aliases)}" if cmd.aliases else ""
|
|
219
|
+
doc = (cmd.help or "").strip().split("\n", 1)[0]
|
|
220
|
+
lines.append(f"`{self.command_prefix}{cmd.name}`{aliases} — {doc}" if doc else f"`{self.command_prefix}{cmd.name}`{aliases}")
|
|
221
|
+
if lines:
|
|
222
|
+
chunk, chunks = "", []
|
|
223
|
+
for line in lines:
|
|
224
|
+
if len(chunk) + len(line) + 1 > 1000:
|
|
225
|
+
chunks.append(chunk)
|
|
226
|
+
chunk = line
|
|
227
|
+
else:
|
|
228
|
+
chunk = f"{chunk}\n{line}" if chunk else line
|
|
229
|
+
if chunk:
|
|
230
|
+
chunks.append(chunk)
|
|
231
|
+
for i, c in enumerate(chunks):
|
|
232
|
+
e.add_field("Commands" if i == 0 else "\u200b", c, inline=False)
|
|
233
|
+
e.set_footer(f"Type {self.command_prefix}<command> or /<command>")
|
|
234
|
+
await ctx.reply(embed=e)
|
|
235
|
+
|
|
236
|
+
async def sync_commands(self):
|
|
237
|
+
"""Push the bot's slash-command definitions to the server.
|
|
238
|
+
|
|
239
|
+
Called automatically on ``on_ready`` when any commands are
|
|
240
|
+
registered with ``slash=True``. Safe to call manually too — it
|
|
241
|
+
replaces the server's stored command list for this bot, so
|
|
242
|
+
calling it a second time with a shorter list deletes any
|
|
243
|
+
commands no longer present.
|
|
244
|
+
|
|
245
|
+
The target application is resolved server-side from the bot
|
|
246
|
+
token (``PUT /api/v1/bot/applications/@me/commands``) — the
|
|
247
|
+
``application_id`` attribute on :class:`Bot` is populated by
|
|
248
|
+
the READY frame for reference but isn't used in this call.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
int: Number of commands the server acknowledged. Zero if
|
|
252
|
+
no slash commands are registered locally.
|
|
253
|
+
"""
|
|
254
|
+
if not self._slash_commands:
|
|
255
|
+
return 0
|
|
256
|
+
result = await self._http.register_commands(self._slash_commands)
|
|
257
|
+
return result.get("registered", len(self._slash_commands)) if result else 0
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def commands(self):
|
|
261
|
+
return self._commands.all()
|
|
262
|
+
|
|
263
|
+
async def process_commands(self, message):
|
|
264
|
+
if self.user is not None and message.user_id == self.user.id:
|
|
265
|
+
return
|
|
266
|
+
content = message.content or ""
|
|
267
|
+
if not content.startswith(self.command_prefix):
|
|
268
|
+
return
|
|
269
|
+
stripped = content[len(self.command_prefix):]
|
|
270
|
+
if not stripped:
|
|
271
|
+
return
|
|
272
|
+
parts = stripped.split(maxsplit=1)
|
|
273
|
+
name = parts[0]
|
|
274
|
+
args_raw = parts[1] if len(parts) > 1 else ""
|
|
275
|
+
cmd = self._commands.get(name)
|
|
276
|
+
if cmd is None:
|
|
277
|
+
return
|
|
278
|
+
ctx = Context(self, message, name, args_raw)
|
|
279
|
+
try:
|
|
280
|
+
await cmd.invoke(ctx)
|
|
281
|
+
except CommandError as e:
|
|
282
|
+
await self._fire("on_command_error", ctx, e)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
await self._fire("on_command_error", ctx, e)
|
|
285
|
+
|
|
286
|
+
async def _dispatch(self, event_type, payload):
|
|
287
|
+
if event_type == "ready":
|
|
288
|
+
user_data = payload.get("user", {})
|
|
289
|
+
self.user = User.from_dict(user_data)
|
|
290
|
+
for g in payload.get("guilds", []) or []:
|
|
291
|
+
guild = Guild.from_dict(g)
|
|
292
|
+
self.guilds[guild.id] = guild
|
|
293
|
+
app_id_from_ready = payload.get("application_id", "")
|
|
294
|
+
if app_id_from_ready and not self.application_id:
|
|
295
|
+
self.application_id = app_id_from_ready
|
|
296
|
+
if self._slash_commands:
|
|
297
|
+
try:
|
|
298
|
+
await self.sync_commands()
|
|
299
|
+
except Exception as e:
|
|
300
|
+
log.warning("command sync failed: %s", e)
|
|
301
|
+
await self._fire("on_ready")
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
if event_type in ("channel_message", "message_create"):
|
|
305
|
+
msg = Message.from_dict(payload, client=self)
|
|
306
|
+
await self._fire("on_message", msg)
|
|
307
|
+
await self.process_commands(msg)
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
if event_type == "message_edit":
|
|
311
|
+
await self._fire("on_message_edit", payload)
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
if event_type == "message_delete":
|
|
315
|
+
await self._fire("on_message_delete", payload)
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
if event_type == "reaction_add":
|
|
319
|
+
await self._fire("on_reaction_add", payload)
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
if event_type == "reaction_remove":
|
|
323
|
+
await self._fire("on_reaction_remove", payload)
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
if event_type == "guild_member_add":
|
|
327
|
+
await self._fire("on_member_join", payload)
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
if event_type == "guild_member_remove":
|
|
331
|
+
await self._fire("on_member_remove", payload)
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
if event_type == "error":
|
|
335
|
+
await self._fire("on_error", payload)
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
await self._fire("on_raw_event", event_type, payload)
|
|
339
|
+
|
|
340
|
+
async def _fire(self, name, *args):
|
|
341
|
+
handler = self._handlers.get(name)
|
|
342
|
+
if handler is None:
|
|
343
|
+
return
|
|
344
|
+
try:
|
|
345
|
+
result = handler(*args)
|
|
346
|
+
if inspect.isawaitable(result):
|
|
347
|
+
await result
|
|
348
|
+
except Exception:
|
|
349
|
+
log.exception("handler %s raised", name)
|
|
350
|
+
|
|
351
|
+
async def send_message(self, channel_id, content="", embed=None, reply_to=""):
|
|
352
|
+
d = await self._http.send_message(channel_id, content=content, embed=embed, reply_to=reply_to)
|
|
353
|
+
return Message.from_dict(d, client=self) if d else None
|
|
354
|
+
|
|
355
|
+
async def get_user(self, user_id):
|
|
356
|
+
d = await self._http.get_user(user_id)
|
|
357
|
+
return User.from_dict(d) if d else None
|
|
358
|
+
|
|
359
|
+
async def get_guild(self, guild_id):
|
|
360
|
+
"""Fetch a guild by ID. Returns a :class:`Guild` or ``None``."""
|
|
361
|
+
d = await self._http.get_guild(guild_id)
|
|
362
|
+
return Guild.from_dict(d) if d else None
|
|
363
|
+
|
|
364
|
+
async def get_member(self, guild_id, user_id):
|
|
365
|
+
"""Fetch a guild member by user ID. Returns :class:`Member` or ``None``."""
|
|
366
|
+
d = await self._http.get_member(guild_id, user_id)
|
|
367
|
+
return Member.from_dict(d, guild_id=guild_id) if d else None
|
|
368
|
+
|
|
369
|
+
async def list_channels(self, guild_id):
|
|
370
|
+
"""List channels in a guild the bot can see. Returns [Channel, ...]."""
|
|
371
|
+
d = await self._http.list_channels(guild_id)
|
|
372
|
+
return [Channel.from_dict(c, client=self) for c in (d or [])]
|
|
373
|
+
|
|
374
|
+
async def get_channel(self, channel_id):
|
|
375
|
+
"""Fetch a single channel by ID. Returns :class:`Channel` or ``None``.
|
|
376
|
+
|
|
377
|
+
Use this when you have only a channel_id (e.g. from a message
|
|
378
|
+
event) and want the full Channel record with its name and
|
|
379
|
+
description, not just the stub from ``msg.channel``.
|
|
380
|
+
"""
|
|
381
|
+
d = await self._http.get_channel(channel_id)
|
|
382
|
+
return Channel.from_dict(d, client=self) if d else None
|
|
383
|
+
|
|
384
|
+
async def list_roles(self, guild_id):
|
|
385
|
+
"""List roles in a guild. Returns [Role, ...]."""
|
|
386
|
+
from .models import Role
|
|
387
|
+
d = await self._http.list_roles(guild_id)
|
|
388
|
+
return [Role.from_dict(r, client=self) for r in (d or [])]
|
|
389
|
+
|
|
390
|
+
async def create_channel(self, guild_id, name, **kwargs):
|
|
391
|
+
"""Create a channel. Requires MANAGE_CHANNELS in the guild.
|
|
392
|
+
|
|
393
|
+
Keyword args are forwarded to
|
|
394
|
+
:meth:`HTTPClient.create_channel` (``description``,
|
|
395
|
+
``category_id``, ``type``). Returns the created
|
|
396
|
+
:class:`Channel` or ``None``.
|
|
397
|
+
"""
|
|
398
|
+
d = await self._http.create_channel(guild_id, name, **kwargs)
|
|
399
|
+
return Channel.from_dict(d, client=self) if d else None
|
|
400
|
+
|
|
401
|
+
async def create_role(self, guild_id, name, **kwargs):
|
|
402
|
+
"""Create a role. Requires MANAGE_ROLES in the guild.
|
|
403
|
+
|
|
404
|
+
Keyword args are forwarded to :meth:`HTTPClient.create_role`
|
|
405
|
+
(``color``, ``permissions``, ...). Returns the created
|
|
406
|
+
:class:`Role` or ``None``.
|
|
407
|
+
"""
|
|
408
|
+
from .models import Role
|
|
409
|
+
d = await self._http.create_role(guild_id, name, **kwargs)
|
|
410
|
+
return Role.from_dict(d, client=self) if d else None
|
|
411
|
+
|
|
412
|
+
async def _start(self, token):
|
|
413
|
+
"""Async entry point driving the connect → run → reconnect loop.
|
|
414
|
+
|
|
415
|
+
Runs until cancelled (Ctrl-C from :meth:`run`) or until a
|
|
416
|
+
non-transient error breaks out. :class:`LoginFailure` from the
|
|
417
|
+
gateway connect step is NOT retried — the token is bad, no
|
|
418
|
+
amount of reconnecting will help.
|
|
419
|
+
|
|
420
|
+
Transient :class:`~banterapi.errors.GatewayError` failures are
|
|
421
|
+
logged and followed by a 5-second sleep before reconnecting
|
|
422
|
+
(when :attr:`reconnect` is truthy). The reconnect delay is
|
|
423
|
+
fixed, not exponential — good enough for a dev-scale bot; a
|
|
424
|
+
production client should layer backoff around this if it's
|
|
425
|
+
reconnecting dozens of times a day.
|
|
426
|
+
"""
|
|
427
|
+
self._http = HTTPClient(token, base_url=self.base_url)
|
|
428
|
+
self._gateway = Gateway(token, self.intents, self.ws_url, self._dispatch)
|
|
429
|
+
try:
|
|
430
|
+
while True:
|
|
431
|
+
try:
|
|
432
|
+
await self._gateway.connect()
|
|
433
|
+
await self._gateway.run()
|
|
434
|
+
except GatewayError as e:
|
|
435
|
+
log.warning("gateway error: %s", e)
|
|
436
|
+
if not self.reconnect:
|
|
437
|
+
break
|
|
438
|
+
log.info("reconnecting in 5s")
|
|
439
|
+
await asyncio.sleep(5)
|
|
440
|
+
finally:
|
|
441
|
+
await self._gateway.close()
|
|
442
|
+
await self._http.close()
|
|
443
|
+
|
|
444
|
+
def run(self, token):
|
|
445
|
+
"""Blocking entry point. Start the bot and run until disconnected.
|
|
446
|
+
|
|
447
|
+
This is the synchronous façade most scripts use:
|
|
448
|
+
``bot.run(TOKEN)`` at the bottom of a file. Internally it spins
|
|
449
|
+
up an asyncio event loop and drives :meth:`_start`.
|
|
450
|
+
|
|
451
|
+
Ctrl-C (``KeyboardInterrupt``) is caught and swallowed on
|
|
452
|
+
purpose — it's the standard shutdown signal for a CLI-launched
|
|
453
|
+
bot, and rethrowing would produce a noisy traceback the user
|
|
454
|
+
can't act on. Non-``KeyboardInterrupt`` exceptions still
|
|
455
|
+
propagate.
|
|
456
|
+
"""
|
|
457
|
+
try:
|
|
458
|
+
asyncio.run(self._start(token))
|
|
459
|
+
except KeyboardInterrupt:
|
|
460
|
+
pass
|