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.
Files changed (26) hide show
  1. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/PKG-INFO +1 -1
  2. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterapi/__init__.py +26 -1
  3. banterbotapi-0.2.2/banterapi/client.py +460 -0
  4. banterbotapi-0.2.2/banterapi/commands.py +410 -0
  5. banterbotapi-0.2.2/banterapi/errors.py +99 -0
  6. banterbotapi-0.2.2/banterapi/gateway.py +239 -0
  7. banterbotapi-0.2.2/banterapi/http.py +514 -0
  8. banterbotapi-0.2.2/banterapi/models.py +417 -0
  9. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterbotapi.egg-info/PKG-INFO +1 -1
  10. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/pyproject.toml +1 -1
  11. banterbotapi-0.2.1/banterapi/client.py +0 -230
  12. banterbotapi-0.2.1/banterapi/commands.py +0 -181
  13. banterbotapi-0.2.1/banterapi/errors.py +0 -39
  14. banterbotapi-0.2.1/banterapi/gateway.py +0 -134
  15. banterbotapi-0.2.1/banterapi/http.py +0 -124
  16. banterbotapi-0.2.1/banterapi/models.py +0 -172
  17. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/LICENSE +0 -0
  18. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/README.md +0 -0
  19. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterapi/embed.py +0 -0
  20. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterapi/intents.py +0 -0
  21. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterapi/permissions.py +0 -0
  22. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterbotapi.egg-info/SOURCES.txt +0 -0
  23. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterbotapi.egg-info/dependency_links.txt +0 -0
  24. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterbotapi.egg-info/requires.txt +0 -0
  25. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/banterbotapi.egg-info/top_level.txt +0 -0
  26. {banterbotapi-0.2.1 → banterbotapi-0.2.2}/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.2
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
@@ -1,4 +1,29 @@
1
- __version__ = "0.2.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