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 +32 -0
- banterapi/client.py +202 -0
- banterapi/commands.py +181 -0
- banterapi/embed.py +57 -0
- banterapi/errors.py +39 -0
- banterapi/gateway.py +134 -0
- banterapi/http.py +124 -0
- banterapi/intents.py +57 -0
- banterapi/models.py +172 -0
- banterapi/permissions.py +50 -0
- banterbotapi-0.1.0.dist-info/METADATA +41 -0
- banterbotapi-0.1.0.dist-info/RECORD +15 -0
- banterbotapi-0.1.0.dist-info/WHEEL +5 -0
- banterbotapi-0.1.0.dist-info/licenses/LICENSE +21 -0
- banterbotapi-0.1.0.dist-info/top_level.txt +1 -0
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)
|
banterapi/permissions.py
ADDED
|
@@ -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,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
|