banterbotapi 0.1.0__py3-none-any.whl

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.
banterapi/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .client import Bot
4
+ from .intents import Intents
5
+ from .embed import Embed
6
+ from .models import User, Guild, Channel, Member, Role, Message
7
+ from .errors import BanterError, HTTPException, Forbidden, NotFound, RateLimited, GatewayError, LoginFailure, MissingPermissions
8
+ from .permissions import Permissions
9
+ from .commands import has_permissions
10
+
11
+ __all__ = [
12
+ "__version__",
13
+ "Bot",
14
+ "Intents",
15
+ "Embed",
16
+ "User",
17
+ "Guild",
18
+ "Channel",
19
+ "Member",
20
+ "Role",
21
+ "Message",
22
+ "Permissions",
23
+ "has_permissions",
24
+ "BanterError",
25
+ "HTTPException",
26
+ "Forbidden",
27
+ "NotFound",
28
+ "RateLimited",
29
+ "GatewayError",
30
+ "LoginFailure",
31
+ "MissingPermissions",
32
+ ]
banterapi/client.py ADDED
@@ -0,0 +1,202 @@
1
+ import asyncio
2
+ import inspect
3
+ import logging
4
+
5
+ from .commands import Command, CommandRegistry, Context, CommandError, CommandNotFound
6
+ from .gateway import Gateway
7
+ from .http import HTTPClient
8
+ from .intents import Intents
9
+ from .models import User, Guild, Channel, Member, Message
10
+ from .errors import GatewayError
11
+
12
+ log = logging.getLogger("banterapi")
13
+
14
+
15
+ class Bot:
16
+ def __init__(self, intents=None, base_url="https://banterchat.org", ws_url=None, reconnect=True, command_prefix="!", application_id=""):
17
+ self.intents = intents if intents is not None else Intents.default()
18
+ self.base_url = base_url.rstrip("/")
19
+ self.ws_url = ws_url or self.base_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/bot/ws"
20
+ self.reconnect = reconnect
21
+ self.command_prefix = command_prefix
22
+ self.application_id = application_id
23
+ self.user = None
24
+ self.guilds = {}
25
+ self._http = None
26
+ self._gateway = None
27
+ self._handlers = {}
28
+ self._commands = CommandRegistry()
29
+ self._slash_commands = []
30
+ self._loop = None
31
+
32
+ def event(self, fn):
33
+ name = fn.__name__
34
+ if not name.startswith("on_"):
35
+ raise ValueError("event handlers must be named on_<event>")
36
+ self._handlers[name] = fn
37
+ return fn
38
+
39
+ def command(self, name=None, aliases=None, help=None, slash=False, description=""):
40
+ def decorator(fn):
41
+ cmd = Command(fn, name=name, aliases=aliases, help=help)
42
+ self._commands.add(cmd)
43
+ if slash:
44
+ self._slash_commands.append({
45
+ "name": cmd.name,
46
+ "description": description or cmd.help or cmd.name,
47
+ })
48
+ return fn
49
+ return decorator
50
+
51
+ def slash_command(self, name=None, description=""):
52
+ def decorator(fn):
53
+ cmd = Command(fn, name=name, help=description)
54
+ self._commands.add(cmd)
55
+ self._slash_commands.append({
56
+ "name": cmd.name,
57
+ "description": description or cmd.help or cmd.name,
58
+ })
59
+ return fn
60
+ return decorator
61
+
62
+ async def sync_commands(self):
63
+ if not self.application_id or not self._slash_commands:
64
+ return 0
65
+ result = await self._http.register_commands(self.application_id, self._slash_commands)
66
+ return result.get("registered", len(self._slash_commands)) if result else 0
67
+
68
+ @property
69
+ def commands(self):
70
+ return self._commands.all()
71
+
72
+ async def process_commands(self, message):
73
+ if self.user is not None and message.user_id == self.user.id:
74
+ return
75
+ content = message.content or ""
76
+ if not content.startswith(self.command_prefix):
77
+ return
78
+ stripped = content[len(self.command_prefix):]
79
+ if not stripped:
80
+ return
81
+ parts = stripped.split(maxsplit=1)
82
+ name = parts[0]
83
+ args_raw = parts[1] if len(parts) > 1 else ""
84
+ cmd = self._commands.get(name)
85
+ if cmd is None:
86
+ return
87
+ ctx = Context(self, message, name, args_raw)
88
+ try:
89
+ await cmd.invoke(ctx)
90
+ except CommandError as e:
91
+ await self._fire("on_command_error", ctx, e)
92
+ except Exception as e:
93
+ await self._fire("on_command_error", ctx, e)
94
+
95
+ async def _dispatch(self, event_type, payload):
96
+ if event_type == "ready":
97
+ user_data = payload.get("user", {})
98
+ self.user = User.from_dict(user_data)
99
+ for g in payload.get("guilds", []) or []:
100
+ guild = Guild.from_dict(g)
101
+ self.guilds[guild.id] = guild
102
+ app_id_from_ready = payload.get("application_id", "")
103
+ if app_id_from_ready and not self.application_id:
104
+ self.application_id = app_id_from_ready
105
+ if self._slash_commands:
106
+ try:
107
+ await self.sync_commands()
108
+ except Exception as e:
109
+ log.warning("command sync failed: %s", e)
110
+ await self._fire("on_ready")
111
+ return
112
+
113
+ if event_type in ("channel_message", "message_create"):
114
+ msg = Message.from_dict(payload, client=self)
115
+ await self._fire("on_message", msg)
116
+ await self.process_commands(msg)
117
+ return
118
+
119
+ if event_type == "message_edit":
120
+ await self._fire("on_message_edit", payload)
121
+ return
122
+
123
+ if event_type == "message_delete":
124
+ await self._fire("on_message_delete", payload)
125
+ return
126
+
127
+ if event_type == "reaction_add":
128
+ await self._fire("on_reaction_add", payload)
129
+ return
130
+
131
+ if event_type == "reaction_remove":
132
+ await self._fire("on_reaction_remove", payload)
133
+ return
134
+
135
+ if event_type == "guild_member_add":
136
+ await self._fire("on_member_join", payload)
137
+ return
138
+
139
+ if event_type == "guild_member_remove":
140
+ await self._fire("on_member_remove", payload)
141
+ return
142
+
143
+ if event_type == "error":
144
+ await self._fire("on_error", payload)
145
+ return
146
+
147
+ await self._fire("on_raw_event", event_type, payload)
148
+
149
+ async def _fire(self, name, *args):
150
+ handler = self._handlers.get(name)
151
+ if handler is None:
152
+ return
153
+ try:
154
+ result = handler(*args)
155
+ if inspect.isawaitable(result):
156
+ await result
157
+ except Exception:
158
+ log.exception("handler %s raised", name)
159
+
160
+ async def send_message(self, channel_id, content="", embed=None, reply_to=""):
161
+ d = await self._http.send_message(channel_id, content=content, embed=embed, reply_to=reply_to)
162
+ return Message.from_dict(d, client=self) if d else None
163
+
164
+ async def get_user(self, user_id):
165
+ d = await self._http.get_user(user_id)
166
+ return User.from_dict(d) if d else None
167
+
168
+ async def get_guild(self, guild_id):
169
+ d = await self._http.get_guild(guild_id)
170
+ return Guild.from_dict(d) if d else None
171
+
172
+ async def get_member(self, guild_id, user_id):
173
+ d = await self._http.get_member(guild_id, user_id)
174
+ return Member.from_dict(d, guild_id=guild_id) if d else None
175
+
176
+ async def list_channels(self, guild_id):
177
+ d = await self._http.list_channels(guild_id)
178
+ return [Channel.from_dict(c, client=self) for c in (d or [])]
179
+
180
+ async def _start(self, token):
181
+ self._http = HTTPClient(token, base_url=self.base_url)
182
+ self._gateway = Gateway(token, self.intents, self.ws_url, self._dispatch)
183
+ try:
184
+ while True:
185
+ try:
186
+ await self._gateway.connect()
187
+ await self._gateway.run()
188
+ except GatewayError as e:
189
+ log.warning("gateway error: %s", e)
190
+ if not self.reconnect:
191
+ break
192
+ log.info("reconnecting in 5s")
193
+ await asyncio.sleep(5)
194
+ finally:
195
+ await self._gateway.close()
196
+ await self._http.close()
197
+
198
+ def run(self, token):
199
+ try:
200
+ asyncio.run(self._start(token))
201
+ except KeyboardInterrupt:
202
+ pass
banterapi/commands.py ADDED
@@ -0,0 +1,181 @@
1
+ import inspect
2
+ import shlex
3
+
4
+ from .errors import MissingPermissions
5
+ from .permissions import has_perm, describe
6
+
7
+
8
+ class CommandError(Exception):
9
+ pass
10
+
11
+
12
+ class CommandNotFound(CommandError):
13
+ pass
14
+
15
+
16
+ class MissingArgument(CommandError):
17
+ def __init__(self, name):
18
+ self.name = name
19
+ super().__init__(f"missing required argument: {name}")
20
+
21
+
22
+ class BadArgument(CommandError):
23
+ def __init__(self, name, value, expected):
24
+ self.name = name
25
+ self.value = value
26
+ self.expected = expected
27
+ super().__init__(f"argument {name!r} expected {expected}, got {value!r}")
28
+
29
+
30
+ _BOOL_TRUE = {"true", "yes", "y", "1", "on", "enable"}
31
+ _BOOL_FALSE = {"false", "no", "n", "0", "off", "disable"}
32
+
33
+
34
+ def _convert(value, annotation, name):
35
+ if annotation is inspect.Parameter.empty or annotation is str:
36
+ return value
37
+ if annotation is int:
38
+ try:
39
+ return int(value)
40
+ except ValueError:
41
+ raise BadArgument(name, value, "integer")
42
+ if annotation is float:
43
+ try:
44
+ return float(value)
45
+ except ValueError:
46
+ raise BadArgument(name, value, "number")
47
+ if annotation is bool:
48
+ v = value.lower()
49
+ if v in _BOOL_TRUE:
50
+ return True
51
+ if v in _BOOL_FALSE:
52
+ return False
53
+ raise BadArgument(name, value, "yes/no")
54
+ return value
55
+
56
+
57
+ class Context:
58
+ def __init__(self, bot, message, invoked_with, args_raw):
59
+ self.bot = bot
60
+ self.message = message
61
+ self.invoked_with = invoked_with
62
+ self.args_raw = args_raw
63
+ self.author = message.author
64
+ self.author_perms = message.author_perms
65
+ self.channel = message.channel
66
+ self.channel_id = message.channel_id
67
+ self.guild_id = message.guild_id
68
+
69
+ def has_permissions(self, required):
70
+ return has_perm(self.author_perms, required)
71
+
72
+ async def send(self, content="", embed=None):
73
+ return await self.bot.send_message(self.channel_id, content=content, embed=embed)
74
+
75
+ async def reply(self, content="", embed=None):
76
+ return await self.message.reply(content=content, embed=embed)
77
+
78
+ async def trigger_typing(self):
79
+ await self.bot._http.trigger_typing(self.channel_id)
80
+
81
+
82
+ def has_permissions(*required):
83
+ """Decorator: block command if invoker lacks any of the given perms.
84
+
85
+ Usage:
86
+ @bot.command(slash=True)
87
+ @has_permissions(Permissions.KICK_MEMBERS)
88
+ async def kick(ctx, target: str): ...
89
+ """
90
+ required_mask = 0
91
+ for r in required:
92
+ required_mask |= r
93
+ def decorator(fn):
94
+ existing = getattr(fn, "_required_perms", 0)
95
+ fn._required_perms = existing | required_mask
96
+ return fn
97
+ return decorator
98
+
99
+
100
+ class Command:
101
+ def __init__(self, func, name=None, aliases=None, help=None):
102
+ self.callback = func
103
+ self.name = name or func.__name__
104
+ self.aliases = tuple(aliases or ())
105
+ self.help = help or (func.__doc__ or "").strip()
106
+ sig = inspect.signature(func)
107
+ params = list(sig.parameters.values())
108
+ if not params:
109
+ raise ValueError(f"command {self.name!r} must accept a context argument")
110
+ self.params = params[1:]
111
+
112
+ def _parse(self, ctx, args_raw):
113
+ try:
114
+ tokens = shlex.split(args_raw) if args_raw else []
115
+ except ValueError:
116
+ tokens = args_raw.split() if args_raw else []
117
+
118
+ positional = []
119
+ kwargs = {}
120
+ i = 0
121
+ for p in self.params:
122
+ if p.kind is inspect.Parameter.VAR_POSITIONAL:
123
+ remaining = tokens[i:]
124
+ positional.extend(_convert(t, p.annotation, p.name) for t in remaining)
125
+ i = len(tokens)
126
+ break
127
+ if p.kind is inspect.Parameter.KEYWORD_ONLY:
128
+ remaining = args_raw
129
+ for _ in range(i):
130
+ remaining = remaining.split(maxsplit=1)[1] if " " in remaining else ""
131
+ remaining = remaining.strip()
132
+ if not remaining and p.default is inspect.Parameter.empty:
133
+ raise MissingArgument(p.name)
134
+ kwargs[p.name] = remaining if remaining else p.default
135
+ i = len(tokens)
136
+ break
137
+ if i >= len(tokens):
138
+ if p.default is inspect.Parameter.empty:
139
+ raise MissingArgument(p.name)
140
+ positional.append(p.default)
141
+ else:
142
+ positional.append(_convert(tokens[i], p.annotation, p.name))
143
+ i += 1
144
+ return positional, kwargs
145
+
146
+ @property
147
+ def required_perms(self):
148
+ return getattr(self.callback, "_required_perms", 0)
149
+
150
+ async def invoke(self, ctx):
151
+ req = self.required_perms
152
+ if req and not has_perm(ctx.author_perms, req):
153
+ missing_bits = req & ~ctx.author_perms
154
+ raise MissingPermissions(describe(missing_bits))
155
+ positional, kwargs = self._parse(ctx, ctx.args_raw)
156
+ result = self.callback(ctx, *positional, **kwargs)
157
+ if inspect.isawaitable(result):
158
+ await result
159
+
160
+
161
+ class CommandRegistry:
162
+ def __init__(self):
163
+ self._commands = {}
164
+
165
+ def add(self, command):
166
+ self._commands[command.name] = command
167
+ for alias in command.aliases:
168
+ self._commands[alias] = command
169
+
170
+ def get(self, name):
171
+ return self._commands.get(name)
172
+
173
+ def all(self):
174
+ seen = set()
175
+ out = []
176
+ for cmd in self._commands.values():
177
+ if cmd.name in seen:
178
+ continue
179
+ seen.add(cmd.name)
180
+ out.append(cmd)
181
+ return out
banterapi/embed.py ADDED
@@ -0,0 +1,57 @@
1
+ class Embed:
2
+ def __init__(self, title="", description="", color=None, url="", image="", thumbnail=""):
3
+ self._data = {}
4
+ if title:
5
+ self._data["title"] = title
6
+ if description:
7
+ self._data["description"] = description
8
+ if color is not None:
9
+ self._data["color"] = color if isinstance(color, str) else f"#{color:06x}"
10
+ if url:
11
+ self._data["url"] = url
12
+ if image:
13
+ self._data["image"] = image
14
+ if thumbnail:
15
+ self._data["thumbnail"] = thumbnail
16
+ self._data["fields"] = []
17
+
18
+ def add_field(self, name, value, inline=False):
19
+ self._data["fields"].append({"name": name, "value": value, "inline": inline})
20
+ return self
21
+
22
+ def set_footer(self, text):
23
+ self._data["footer"] = text
24
+ return self
25
+
26
+ def set_author(self, name):
27
+ self._data["author"] = name
28
+ return self
29
+
30
+ def set_image(self, url):
31
+ self._data["image"] = url
32
+ return self
33
+
34
+ def set_thumbnail(self, url):
35
+ self._data["thumbnail"] = url
36
+ return self
37
+
38
+ def add_button(self, label, style="secondary", url="", emoji="", disabled=False, id=""):
39
+ btn = {"label": label, "style": style}
40
+ if url:
41
+ btn["url"] = url
42
+ if emoji:
43
+ btn["emoji"] = emoji
44
+ if disabled:
45
+ btn["disabled"] = True
46
+ if id:
47
+ btn["id"] = id
48
+ self._data.setdefault("buttons", []).append(btn)
49
+ return self
50
+
51
+ def to_dict(self):
52
+ d = dict(self._data)
53
+ if not d.get("fields"):
54
+ d.pop("fields", None)
55
+ if not d.get("buttons"):
56
+ d.pop("buttons", None)
57
+ return d
banterapi/errors.py ADDED
@@ -0,0 +1,39 @@
1
+ class BanterError(Exception):
2
+ pass
3
+
4
+
5
+ class HTTPException(BanterError):
6
+ def __init__(self, status, code, message):
7
+ self.status = status
8
+ self.code = code
9
+ self.message = message
10
+ super().__init__(f"{status} {code}: {message}")
11
+
12
+
13
+ class Forbidden(HTTPException):
14
+ pass
15
+
16
+
17
+ class NotFound(HTTPException):
18
+ pass
19
+
20
+
21
+ class RateLimited(HTTPException):
22
+ def __init__(self, status, code, message, retry_after):
23
+ super().__init__(status, code, message)
24
+ self.retry_after = retry_after
25
+
26
+
27
+ class GatewayError(BanterError):
28
+ pass
29
+
30
+
31
+ class LoginFailure(BanterError):
32
+ pass
33
+
34
+
35
+ class MissingPermissions(BanterError):
36
+ def __init__(self, missing):
37
+ self.missing = missing
38
+ names = ", ".join(missing) if missing else "unknown"
39
+ super().__init__(f"Missing permissions: {names}")
banterapi/gateway.py ADDED
@@ -0,0 +1,134 @@
1
+ import asyncio
2
+ import json
3
+
4
+ import websockets
5
+
6
+ from .errors import GatewayError, LoginFailure
7
+
8
+
9
+ OP_DISPATCH = 0
10
+ OP_HEARTBEAT = 1
11
+ OP_HELLO = 10
12
+ OP_HEARTBEAT_ACK = 11
13
+
14
+
15
+ def _headers_kwarg():
16
+ try:
17
+ from inspect import signature
18
+ params = signature(websockets.connect).parameters
19
+ except Exception:
20
+ return "additional_headers"
21
+ if "additional_headers" in params:
22
+ return "additional_headers"
23
+ if "extra_headers" in params:
24
+ return "extra_headers"
25
+ return "additional_headers"
26
+
27
+
28
+ class Gateway:
29
+ def __init__(self, token, intents, ws_url, dispatcher):
30
+ self.token = token
31
+ self.intents = int(intents)
32
+ self.ws_url = ws_url
33
+ self.dispatcher = dispatcher
34
+ self._ws = None
35
+ self._closed = False
36
+ self._heartbeat_task = None
37
+ self._heartbeat_interval = None
38
+ self._last_ack = True
39
+
40
+ async def connect(self):
41
+ url = f"{self.ws_url}?intents={self.intents}"
42
+ kwargs = {
43
+ _headers_kwarg(): {
44
+ "Authorization": f"Bot {self.token}",
45
+ "User-Agent": "BanterPy/0.1",
46
+ },
47
+ "ping_interval": 20,
48
+ "ping_timeout": 20,
49
+ }
50
+ try:
51
+ self._ws = await websockets.connect(url, **kwargs)
52
+ except Exception as e:
53
+ name = type(e).__name__
54
+ if name in ("InvalidStatus", "InvalidStatusCode"):
55
+ status = 0
56
+ resp = getattr(e, "response", None)
57
+ if resp is not None:
58
+ status = getattr(resp, "status_code", 0)
59
+ if status == 0:
60
+ status = getattr(e, "status_code", 0)
61
+ if status == 401:
62
+ raise LoginFailure("invalid bot token")
63
+ raise GatewayError(f"connect failed: HTTP {status}")
64
+ raise GatewayError(f"connect failed: {e}")
65
+
66
+ async def _heartbeat_loop(self):
67
+ try:
68
+ while not self._closed:
69
+ await asyncio.sleep(self._heartbeat_interval / 1000)
70
+ if not self._last_ack:
71
+ try:
72
+ await self._ws.close(code=4000, reason="missed heartbeat ack")
73
+ except Exception:
74
+ pass
75
+ return
76
+ self._last_ack = False
77
+ try:
78
+ await self._ws.send(json.dumps({"op": OP_HEARTBEAT, "d": None}))
79
+ except Exception:
80
+ return
81
+ except asyncio.CancelledError:
82
+ return
83
+
84
+ async def run(self):
85
+ if self._ws is None:
86
+ raise GatewayError("not connected")
87
+ try:
88
+ async for raw in self._ws:
89
+ if self._closed:
90
+ return
91
+ try:
92
+ msg = json.loads(raw)
93
+ except json.JSONDecodeError:
94
+ continue
95
+
96
+ if isinstance(msg, dict) and "op" in msg:
97
+ op = msg.get("op")
98
+ if op == OP_HELLO:
99
+ d = msg.get("d") or {}
100
+ self._heartbeat_interval = int(d.get("heartbeat_interval", 41250))
101
+ self._last_ack = True
102
+ if self._heartbeat_task is None or self._heartbeat_task.done():
103
+ self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
104
+ continue
105
+ if op == OP_HEARTBEAT_ACK:
106
+ self._last_ack = True
107
+ continue
108
+ if op == OP_DISPATCH:
109
+ event_type = msg.get("t") or ""
110
+ payload = msg.get("d") or {}
111
+ await self.dispatcher(event_type, payload)
112
+ continue
113
+
114
+ event_type = msg.get("type", "")
115
+ payload = msg.get("payload", {})
116
+ await self.dispatcher(event_type, payload)
117
+ except websockets.exceptions.ConnectionClosed:
118
+ return
119
+
120
+ async def close(self):
121
+ self._closed = True
122
+ if self._heartbeat_task is not None and not self._heartbeat_task.done():
123
+ self._heartbeat_task.cancel()
124
+ if self._ws is None:
125
+ return
126
+ closed = False
127
+ state = getattr(self._ws, "closed", None)
128
+ if state is not None:
129
+ closed = bool(state)
130
+ if not closed:
131
+ try:
132
+ await self._ws.close()
133
+ except Exception:
134
+ pass
banterapi/http.py ADDED
@@ -0,0 +1,124 @@
1
+ import asyncio
2
+ from http import HTTPStatus
3
+
4
+ import aiohttp
5
+
6
+ from .errors import HTTPException, Forbidden, NotFound, RateLimited, LoginFailure
7
+
8
+
9
+ class HTTPClient:
10
+ def __init__(self, token, base_url="https://banterchat.org"):
11
+ self.token = token
12
+ self.base_url = base_url.rstrip("/")
13
+ self._session = None
14
+
15
+ async def _ensure_session(self):
16
+ if self._session is None or self._session.closed:
17
+ self._session = aiohttp.ClientSession(
18
+ headers={
19
+ "Authorization": f"Bot {self.token}",
20
+ "User-Agent": "BanterPy/0.1",
21
+ }
22
+ )
23
+
24
+ async def close(self):
25
+ if self._session and not self._session.closed:
26
+ await self._session.close()
27
+
28
+ async def request(self, method, path, json_body=None, params=None):
29
+ await self._ensure_session()
30
+ url = self.base_url + path
31
+ for attempt in range(2):
32
+ async with self._session.request(method, url, json=json_body, params=params) as resp:
33
+ if resp.status == HTTPStatus.NO_CONTENT:
34
+ return None
35
+ text = await resp.text()
36
+ data = None
37
+ if text:
38
+ try:
39
+ data = await resp.json(content_type=None)
40
+ except Exception:
41
+ data = {"message": text}
42
+ if HTTPStatus.OK <= resp.status < HTTPStatus.MULTIPLE_CHOICES:
43
+ return data
44
+ code = (data or {}).get("code", 0)
45
+ message = (data or {}).get("message", text or "unknown error")
46
+ if resp.status == HTTPStatus.TOO_MANY_REQUESTS:
47
+ retry_after = float(resp.headers.get("X-RateLimit-Reset-After", "1"))
48
+ if attempt == 0:
49
+ await asyncio.sleep(retry_after)
50
+ continue
51
+ raise RateLimited(resp.status, code, message, retry_after)
52
+ if resp.status == HTTPStatus.UNAUTHORIZED:
53
+ raise LoginFailure(message)
54
+ if resp.status == HTTPStatus.FORBIDDEN:
55
+ raise Forbidden(resp.status, code, message)
56
+ if resp.status == HTTPStatus.NOT_FOUND:
57
+ raise NotFound(resp.status, code, message)
58
+ raise HTTPException(resp.status, code, message)
59
+ raise HTTPException(0, 0, "request failed")
60
+
61
+ async def me(self):
62
+ return await self.request("GET", "/api/me")
63
+
64
+ async def get_user(self, user_id):
65
+ return await self.request("GET", f"/api/users/{user_id}")
66
+
67
+ async def get_guild(self, guild_id):
68
+ return await self.request("GET", f"/api/guilds/{guild_id}")
69
+
70
+ async def get_member(self, guild_id, user_id):
71
+ return await self.request("GET", f"/api/guilds/{guild_id}/members/{user_id}")
72
+
73
+ async def list_channels(self, guild_id):
74
+ return await self.request("GET", f"/api/guilds/{guild_id}/channels")
75
+
76
+ async def list_roles(self, guild_id):
77
+ return await self.request("GET", f"/api/guilds/{guild_id}/roles")
78
+
79
+ async def list_messages(self, channel_id, before="", limit=50):
80
+ params = {"limit": limit}
81
+ if before:
82
+ params["before"] = before
83
+ return await self.request("GET", f"/api/channels/{channel_id}/messages", params=params)
84
+
85
+ async def send_message(self, channel_id, content="", embed=None, reply_to=""):
86
+ body = {"content": content}
87
+ if embed is not None:
88
+ body["embed"] = embed.to_dict() if hasattr(embed, "to_dict") else embed
89
+ if reply_to:
90
+ body["reply_to"] = reply_to
91
+ return await self.request("POST", f"/api/channels/{channel_id}/messages", json_body=body)
92
+
93
+ async def edit_message(self, message_id, content):
94
+ return await self.request("PATCH", f"/api/messages/{message_id}", json_body={"content": content})
95
+
96
+ async def delete_message(self, message_id):
97
+ return await self.request("DELETE", f"/api/messages/{message_id}")
98
+
99
+ async def trigger_typing(self, channel_id):
100
+ return await self.request("POST", f"/api/channels/{channel_id}/typing")
101
+
102
+ async def add_reaction(self, channel_id, message_id, emoji):
103
+ return await self.request("PUT", f"/api/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/@me")
104
+
105
+ async def remove_reaction(self, channel_id, message_id, emoji):
106
+ return await self.request("DELETE", f"/api/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/@me")
107
+
108
+ async def add_role(self, guild_id, user_id, role_id):
109
+ return await self.request("PUT", f"/api/guilds/{guild_id}/members/{user_id}/roles/{role_id}")
110
+
111
+ async def remove_role(self, guild_id, user_id, role_id):
112
+ return await self.request("DELETE", f"/api/guilds/{guild_id}/members/{user_id}/roles/{role_id}")
113
+
114
+ async def kick_member(self, guild_id, user_id):
115
+ return await self.request("POST", f"/api/guilds/{guild_id}/members/{user_id}/kick")
116
+
117
+ async def ban_member(self, guild_id, user_id, reason=""):
118
+ return await self.request("POST", f"/api/guilds/{guild_id}/members/{user_id}/ban", json_body={"reason": reason})
119
+
120
+ async def unban_member(self, guild_id, user_id):
121
+ return await self.request("DELETE", f"/api/guilds/{guild_id}/members/{user_id}/ban")
122
+
123
+ async def register_commands(self, app_id, commands):
124
+ return await self.request("PUT", f"/api/applications/{app_id}/commands", json_body={"commands": commands})
banterapi/intents.py ADDED
@@ -0,0 +1,57 @@
1
+ class Intents:
2
+ GUILDS = 1 << 0
3
+ GUILD_MEMBERS = 1 << 1
4
+ GUILD_MODERATION = 1 << 2
5
+ GUILD_PRESENCES = 1 << 3
6
+ GUILD_MESSAGES = 1 << 4
7
+ GUILD_REACTIONS = 1 << 5
8
+ GUILD_TYPING = 1 << 6
9
+ GUILD_VOICE_STATES = 1 << 7
10
+ DIRECT_MESSAGES = 1 << 8
11
+ DIRECT_REACTIONS = 1 << 9
12
+ DIRECT_TYPING = 1 << 10
13
+ MESSAGE_CONTENT = 1 << 11
14
+ BOT_EVENTS = 1 << 12
15
+
16
+ def __init__(self, value=0):
17
+ self.value = value
18
+
19
+ def __or__(self, other):
20
+ if isinstance(other, Intents):
21
+ return Intents(self.value | other.value)
22
+ return Intents(self.value | int(other))
23
+
24
+ def __int__(self):
25
+ return self.value
26
+
27
+ @classmethod
28
+ def default(cls):
29
+ return cls(
30
+ cls.GUILDS
31
+ | cls.GUILD_MESSAGES
32
+ | cls.GUILD_REACTIONS
33
+ | cls.GUILD_MEMBERS
34
+ | cls.BOT_EVENTS
35
+ )
36
+
37
+ @classmethod
38
+ def all(cls):
39
+ return cls(
40
+ cls.GUILDS
41
+ | cls.GUILD_MEMBERS
42
+ | cls.GUILD_MODERATION
43
+ | cls.GUILD_PRESENCES
44
+ | cls.GUILD_MESSAGES
45
+ | cls.GUILD_REACTIONS
46
+ | cls.GUILD_TYPING
47
+ | cls.GUILD_VOICE_STATES
48
+ | cls.DIRECT_MESSAGES
49
+ | cls.DIRECT_REACTIONS
50
+ | cls.DIRECT_TYPING
51
+ | cls.MESSAGE_CONTENT
52
+ | cls.BOT_EVENTS
53
+ )
54
+
55
+ @classmethod
56
+ def none(cls):
57
+ return cls(0)
banterapi/models.py ADDED
@@ -0,0 +1,172 @@
1
+ import functools
2
+ from dataclasses import dataclass, field
3
+ from typing import List, Optional
4
+
5
+
6
+ def _requires_client(fn):
7
+ @functools.wraps(fn)
8
+ async def wrapper(self, *args, **kwargs):
9
+ if self._client is None:
10
+ raise RuntimeError(f"{type(self).__name__} detached from client")
11
+ return await fn(self, *args, **kwargs)
12
+ return wrapper
13
+
14
+
15
+ @dataclass
16
+ class User:
17
+ id: str
18
+ username: str
19
+ display_name: str = ""
20
+ avatar_id: str = ""
21
+ bot: bool = False
22
+
23
+ @classmethod
24
+ def from_dict(cls, d):
25
+ return cls(
26
+ id=d.get("id", ""),
27
+ username=d.get("username", ""),
28
+ display_name=d.get("display_name", ""),
29
+ avatar_id=d.get("avatar", "") or d.get("avatar_id", ""),
30
+ bot=d.get("is_bot", False) or d.get("bot", False),
31
+ )
32
+
33
+
34
+ @dataclass
35
+ class Role:
36
+ id: str
37
+ name: str
38
+ color: str = ""
39
+ position: int = 0
40
+ permissions: int = 0
41
+
42
+ @classmethod
43
+ def from_dict(cls, d):
44
+ return cls(
45
+ id=d.get("id", ""),
46
+ name=d.get("name", ""),
47
+ color=d.get("color", ""),
48
+ position=d.get("position", 0),
49
+ permissions=int(d.get("permissions", 0)),
50
+ )
51
+
52
+
53
+ @dataclass
54
+ class Channel:
55
+ id: str
56
+ name: str
57
+ guild_id: str = ""
58
+ type: str = "text"
59
+ description: str = ""
60
+
61
+ _client: object = field(default=None, repr=False, compare=False)
62
+
63
+ @classmethod
64
+ def from_dict(cls, d, client=None):
65
+ return cls(
66
+ id=d.get("id", ""),
67
+ name=d.get("name", ""),
68
+ guild_id=d.get("guild_id", ""),
69
+ type=d.get("type", "text"),
70
+ description=d.get("description", ""),
71
+ _client=client,
72
+ )
73
+
74
+ @_requires_client
75
+ async def send(self, content="", embed=None, reply_to=""):
76
+ return await self._client.send_message(self.id, content=content, embed=embed, reply_to=reply_to)
77
+
78
+ @_requires_client
79
+ async def trigger_typing(self):
80
+ await self._client._http.trigger_typing(self.id)
81
+
82
+
83
+ @dataclass
84
+ class Member:
85
+ user: User
86
+ guild_id: str
87
+ roles: List[str] = field(default_factory=list)
88
+
89
+ @classmethod
90
+ def from_dict(cls, d, guild_id=""):
91
+ return cls(
92
+ user=User.from_dict(d),
93
+ guild_id=guild_id or d.get("guild_id", ""),
94
+ roles=d.get("roles", []) or [],
95
+ )
96
+
97
+
98
+ @dataclass
99
+ class Guild:
100
+ id: str
101
+ name: str
102
+ owner_id: str = ""
103
+ icon: str = ""
104
+ description: str = ""
105
+ member_count: int = 0
106
+
107
+ @classmethod
108
+ def from_dict(cls, d):
109
+ return cls(
110
+ id=d.get("id", ""),
111
+ name=d.get("name", ""),
112
+ owner_id=d.get("owner_id", ""),
113
+ icon=d.get("icon", ""),
114
+ description=d.get("description", ""),
115
+ member_count=d.get("member_count", 0),
116
+ )
117
+
118
+
119
+ @dataclass
120
+ class Message:
121
+ id: str
122
+ channel_id: str
123
+ user_id: str
124
+ content: str
125
+ author: Optional[User] = None
126
+ guild_id: str = ""
127
+ type: str = "message"
128
+ reply_to: str = ""
129
+ edited: bool = False
130
+ created_at: str = ""
131
+ author_perms: int = 0
132
+
133
+ _client: object = field(default=None, repr=False, compare=False)
134
+
135
+ @classmethod
136
+ def from_dict(cls, d, client=None):
137
+ return cls(
138
+ id=d.get("id", ""),
139
+ channel_id=d.get("channel_id", ""),
140
+ user_id=d.get("user_id", ""),
141
+ content=d.get("content", ""),
142
+ author=User.from_dict(d) if d.get("username") else None,
143
+ guild_id=d.get("guild_id", ""),
144
+ type=d.get("type", "message"),
145
+ reply_to=d.get("reply_to", ""),
146
+ edited=d.get("edited", False),
147
+ created_at=d.get("created_at", ""),
148
+ author_perms=int(d.get("author_perms", 0)),
149
+ _client=client,
150
+ )
151
+
152
+ @property
153
+ def channel(self):
154
+ if self._client is None:
155
+ raise RuntimeError("Message detached from client")
156
+ return Channel(id=self.channel_id, name="", guild_id=self.guild_id, _client=self._client)
157
+
158
+ @_requires_client
159
+ async def reply(self, content="", embed=None):
160
+ return await self._client.send_message(self.channel_id, content=content, embed=embed, reply_to=self.id)
161
+
162
+ @_requires_client
163
+ async def edit(self, content):
164
+ return await self._client._http.edit_message(self.id, content)
165
+
166
+ @_requires_client
167
+ async def delete(self):
168
+ return await self._client._http.delete_message(self.id)
169
+
170
+ @_requires_client
171
+ async def add_reaction(self, emoji):
172
+ return await self._client._http.add_reaction(self.channel_id, self.id, emoji)
@@ -0,0 +1,50 @@
1
+ """Permission bits — mirror modules/permissions/perms.go exactly."""
2
+
3
+
4
+ class Permissions:
5
+ SEND_MESSAGES = 1 << 0
6
+ MANAGE_CHANNELS = 1 << 1
7
+ MANAGE_ROLES = 1 << 2
8
+ MANAGE_MESSAGES = 1 << 3
9
+ ADMINISTRATOR = 1 << 4
10
+ MENTION_EVERYONE = 1 << 5
11
+ VIEW_CHANNELS = 1 << 6
12
+ ATTACH_FILES = 1 << 7
13
+ BAN_MEMBERS = 1 << 8
14
+ USE_SLASH_COMMANDS = 1 << 9
15
+ MANAGE_GUILD = 1 << 10
16
+ KICK_MEMBERS = 1 << 11
17
+
18
+ ALL = (
19
+ SEND_MESSAGES | MANAGE_CHANNELS | MANAGE_ROLES | MANAGE_MESSAGES
20
+ | ADMINISTRATOR | MENTION_EVERYONE | VIEW_CHANNELS | ATTACH_FILES
21
+ | BAN_MEMBERS | USE_SLASH_COMMANDS | MANAGE_GUILD | KICK_MEMBERS
22
+ )
23
+
24
+
25
+ _NAMES = {
26
+ Permissions.SEND_MESSAGES: "send_messages",
27
+ Permissions.MANAGE_CHANNELS: "manage_channels",
28
+ Permissions.MANAGE_ROLES: "manage_roles",
29
+ Permissions.MANAGE_MESSAGES: "manage_messages",
30
+ Permissions.ADMINISTRATOR: "administrator",
31
+ Permissions.MENTION_EVERYONE: "mention_everyone",
32
+ Permissions.VIEW_CHANNELS: "view_channels",
33
+ Permissions.ATTACH_FILES: "attach_files",
34
+ Permissions.BAN_MEMBERS: "ban_members",
35
+ Permissions.USE_SLASH_COMMANDS: "use_slash_commands",
36
+ Permissions.MANAGE_GUILD: "manage_guild",
37
+ Permissions.KICK_MEMBERS: "kick_members",
38
+ }
39
+
40
+
41
+ def has_perm(bitmask, required):
42
+ """True if bitmask has every bit in required (or Administrator)."""
43
+ if bitmask & Permissions.ADMINISTRATOR:
44
+ return True
45
+ return (bitmask & required) == required
46
+
47
+
48
+ def describe(bitmask):
49
+ """Return a sorted list of permission names present in a bitmask."""
50
+ return sorted(name for bit, name in _NAMES.items() if bitmask & bit)
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: banterbotapi
3
+ Version: 0.1.0
4
+ Summary: Python SDK for building bots on Banter (banterchat.org) — discord.py-style.
5
+ Author-email: Banter <contact@banterchat.org>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://banterchat.org
8
+ Project-URL: Repository, https://banterchat.org
9
+ Keywords: banter,bot,chat,sdk,discord-py-style
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Communications :: Chat
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: aiohttp>=3.9
23
+ Requires-Dist: websockets>=12
24
+ Dynamic: license-file
25
+
26
+ # banterbotapi
27
+
28
+ Python SDK for building bots on [Banter](https://banterchat.org).
29
+
30
+ ```bash
31
+ pip install banterbotapi
32
+ ```
33
+
34
+ ```python
35
+ from banterapi import Bot, Intents
36
+
37
+ bot = Bot(intents=Intents.default() | Intents.MESSAGE_CONTENT)
38
+ bot.run("YOUR_TOKEN")
39
+ ```
40
+
41
+ Install name is `banterbotapi`, import name is `banterapi` (same pattern as Pillow/PIL).
@@ -0,0 +1,15 @@
1
+ banterapi/__init__.py,sha256=pmeTX7WSfv8MPmmQGuQ8UUTRuRTWFSDL-JrivOHlBOI,722
2
+ banterapi/client.py,sha256=vpOxj5TulowKEL4ECoVIs9b3S9SinEvpSBQr2fYwYhI,7307
3
+ banterapi/commands.py,sha256=XJAyA3OFe91YQasvB05CNufuRu6KWolbRrKl5bc-TF0,5678
4
+ banterapi/embed.py,sha256=KEBqhwQRJgqjbpnOXkeXrPz6SibIo39XrH60ZiBPDxk,1726
5
+ banterapi/errors.py,sha256=3F39ixbGWM1jgGyKDsILtUAyTW1ecDRYQbrr4RZ5KmU,842
6
+ banterapi/gateway.py,sha256=UfDHGhPiIn8_Bq5fn3mcPMt0ptlAGeaqFILtT-ZH8no,4560
7
+ banterapi/http.py,sha256=19_7P86dA4t_05I1EYpy-DoNHwuX9vziLlYTF-QzlrQ,5546
8
+ banterapi/intents.py,sha256=mKyX3xjJokfN44RCW7YtqxHdCjXfTxhmngMR8_ovqQY,1427
9
+ banterapi/models.py,sha256=h7ZF0xT19AeG1LENDsbXID9xfaKvVZbomOFvDyX4ijA,4637
10
+ banterapi/permissions.py,sha256=v5ejXI-WIDCFWi02lUmbgBewBOMVSblRXWrBCkIDNn4,1759
11
+ banterbotapi-0.1.0.dist-info/licenses/LICENSE,sha256=mx5Ya5ys00JkaDHrHlsLRDbY5aVV2d7UBj6iLQiZP5E,1063
12
+ banterbotapi-0.1.0.dist-info/METADATA,sha256=St-NcBW7pwL-uNhocG5VtuSOdFhun2E9cJ9bpQ-RwAE,1322
13
+ banterbotapi-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
+ banterbotapi-0.1.0.dist-info/top_level.txt,sha256=bxmO_qHXaY7yZU8W3msDRKvPYPh__AOleanD4ci7CKs,10
15
+ banterbotapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Banter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ banterapi