python-yappy 0.1.1__py3-none-any.whl → 0.1.2__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.
- {python_yappy-0.1.1.dist-info → python_yappy-0.1.2.dist-info}/METADATA +65 -3
- python_yappy-0.1.2.dist-info/RECORD +12 -0
- yappy/__init__.py +5 -0
- yappy/api.py +18 -3
- yappy/app.py +218 -92
- yappy/core.py +109 -28
- yappy/errors.py +75 -4
- yappy/logger.py +69 -0
- yappy/parser.py +105 -20
- python_yappy-0.1.1.dist-info/RECORD +0 -11
- {python_yappy-0.1.1.dist-info → python_yappy-0.1.2.dist-info}/WHEEL +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: python-yappy
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: API Bindings for Yapper!
|
|
5
5
|
Author: H1387Lmao
|
|
6
6
|
Requires-Dist: aiohttp>=3.14.5
|
|
7
7
|
Requires-Dist: load-dotenv>=0.1.0
|
|
8
|
+
Requires-Dist: rich>=13.0
|
|
8
9
|
Requires-Dist: uv-build>=0.13.1
|
|
9
10
|
Requires-Python: >=3.14
|
|
10
11
|
Description-Content-Type: text/markdown
|
|
@@ -24,7 +25,8 @@ YapPy brings a Python-friendly interface to [Yapper](https://yapper.website), a
|
|
|
24
25
|
- **Commands** — register named commands with descriptions.
|
|
25
26
|
- **Context-based interactions** — reply to messages through a context object.
|
|
26
27
|
- **Core models** — work with `Context`, `Message`, `Channel`, and `User` objects.
|
|
27
|
-
- **
|
|
28
|
+
- **Argument validation** — type-hinted command parameters are converted for you, and mistakes get a helpful reply.
|
|
29
|
+
- **Rich logging** — coloured logs and readable tracebacks; `verbose=True` adds debug output.
|
|
28
30
|
|
|
29
31
|
## Installation
|
|
30
32
|
|
|
@@ -114,7 +116,7 @@ async def on_message(ctx):
|
|
|
114
116
|
if ctx.is_bot:
|
|
115
117
|
return
|
|
116
118
|
|
|
117
|
-
await
|
|
119
|
+
await bot.process_command(ctx)
|
|
118
120
|
```
|
|
119
121
|
|
|
120
122
|
The event name and handler arguments should match the event interface supported by the installed YapPy version.
|
|
@@ -134,6 +136,66 @@ async def ping(ctx):
|
|
|
134
136
|
|
|
135
137
|
The `name` identifies the command, and `description` describes its purpose. Command registration and invocation behavior depend on the current implementation.
|
|
136
138
|
|
|
139
|
+
### Command arguments
|
|
140
|
+
|
|
141
|
+
Arguments from the message are matched to your function's parameters. Add type hints and YapPy converts them for you:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
@bot.command(name="add", description="Adds two numbers")
|
|
145
|
+
async def add(ctx, a: int, b: int = 0):
|
|
146
|
+
await ctx.reply(f"{a} + {b} = {a + b}")
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
| Annotation | Accepts |
|
|
150
|
+
| --- | --- |
|
|
151
|
+
| `str` | the text exactly as typed (use `"quotes"` for spaces) |
|
|
152
|
+
| `int`, `float` | numbers |
|
|
153
|
+
| `bool` | `true`/`false`, `yes`/`no`, `on`/`off`, `1`/`0` |
|
|
154
|
+
| `User`, `Channel`, `Role` | a mention such as `<@123>` |
|
|
155
|
+
| *(none)* | guessed: whole numbers become `int`, decimals `float`, mentions are resolved, anything else is `str` |
|
|
156
|
+
|
|
157
|
+
Parameters with a default are optional, and `*args` collects any remaining arguments.
|
|
158
|
+
|
|
159
|
+
### Command errors
|
|
160
|
+
|
|
161
|
+
If someone uses a command the wrong way, the bot tells them instead of failing silently:
|
|
162
|
+
|
|
163
|
+
```text
|
|
164
|
+
!add
|
|
165
|
+
Missing required argument 'a'.
|
|
166
|
+
Usage: `!add <a> [b]`
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Too many arguments and values of the wrong type are handled the same way. Unknown commands are ignored, and an exception raised inside a command is logged rather than sent to chat.
|
|
170
|
+
|
|
171
|
+
To handle these yourself, register a `command_error` handler. It replaces the default replies:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from yappy import UnknownCommand
|
|
175
|
+
|
|
176
|
+
@bot.event("command_error")
|
|
177
|
+
async def on_command_error(ctx, error):
|
|
178
|
+
if isinstance(error, UnknownCommand):
|
|
179
|
+
return
|
|
180
|
+
await ctx.reply(str(error))
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`error` is one of `UnknownCommand`, `MissingArgument`, `TooManyArguments`, `BadArgument` or `CommandInvokeError` (all subclasses of `yappy.CommandError`). For `CommandInvokeError`, the exception your command raised is `error.original`, and `error.command` is the command involved.
|
|
184
|
+
|
|
185
|
+
### Logging
|
|
186
|
+
|
|
187
|
+
YapPy logs through Python's `logging` module under the name `yappy` and prints it with [Rich](https://github.com/Textualize/rich). Exceptions appear as readable tracebacks. `App(verbose=True)` also shows debug output: events, HTTP requests and command invocations.
|
|
188
|
+
|
|
189
|
+
To configure logging yourself, create the app with `configure_logging=False`:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
import logging
|
|
193
|
+
from yappy import App, setup_logging
|
|
194
|
+
|
|
195
|
+
bot = App(configure_logging=False)
|
|
196
|
+
setup_logging(level=logging.WARNING, show_locals=True) # or attach your own handlers to logging.getLogger("yappy")
|
|
197
|
+
```
|
|
198
|
+
|
|
137
199
|
### Context and models
|
|
138
200
|
|
|
139
201
|
YapPy currently provides these core models:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
yappy/__init__.py,sha256=sfTl69d_NnVtVVLFQbQRmnsTqv8xjC4swCBL-FGxSjc,197
|
|
2
|
+
yappy/api.py,sha256=1j99nfK7n5FBl-vSZy98xtZqh5oBtZZEcFKY4cbHTSs,2854
|
|
3
|
+
yappy/app.py,sha256=-vn22pj5pUBxSLZPiqGZfeJDjmrG44Pu5NTLXQBESec,17300
|
|
4
|
+
yappy/consts.py,sha256=0pIUIY1Ayo6_q3XhWr9hvKLgOT7TC9RgsjPZNVYy8QQ,51
|
|
5
|
+
yappy/core.py,sha256=Z_Yc6cNqN_1qThGVZZ94bpjwMnWHaPlRqhYS3GGRRf0,11104
|
|
6
|
+
yappy/errors.py,sha256=x-CHCU1s4lhZVYqK9EwULYJBbIll8Bt09cC7dZ7Y8oI,2297
|
|
7
|
+
yappy/logger.py,sha256=IVjOwvFtaYLwWGHNurTk7SVcthT8gi02ztuuayBlyIY,2295
|
|
8
|
+
yappy/parser.py,sha256=yOp74Ch8RZ6nAadFVrRxKt5U4VF5rqIClSjWRXmcPAI,5468
|
|
9
|
+
yappy/utils.py,sha256=s7wmPTflqTAIgeAtf0a-gWgEfkQaZGKWYEqm2i4MsUM,129
|
|
10
|
+
python_yappy-0.1.2.dist-info/WHEEL,sha256=lrO5MD1WVAWzcbNy_L2BwtfPrcM3KfUFpbKYXLkJX4A,80
|
|
11
|
+
python_yappy-0.1.2.dist-info/METADATA,sha256=hEgEHzEIx6QaNLAP0uzt1qxaAsYdxaOqKzxXeiDxJMY,7103
|
|
12
|
+
python_yappy-0.1.2.dist-info/RECORD,,
|
yappy/__init__.py
CHANGED
yappy/api.py
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
from .consts import BASE_URL
|
|
2
2
|
import aiohttp
|
|
3
|
+
import logging
|
|
3
4
|
import random
|
|
4
5
|
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
5
9
|
|
|
6
10
|
async def api_push(session: aiohttp.ClientSession, method: str, path: str, body=None, tries=3):
|
|
7
11
|
headers = {
|
|
@@ -9,10 +13,17 @@ async def api_push(session: aiohttp.ClientSession, method: str, path: str, body=
|
|
|
9
13
|
"Content-Type": "application/json"
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
started = time.perf_counter()
|
|
12
17
|
try:
|
|
13
18
|
async with session.request(method, BASE_URL + path, json=body, headers=headers, timeout=40) as r:
|
|
14
19
|
r.raise_for_status()
|
|
15
|
-
|
|
20
|
+
data = await r.json()
|
|
21
|
+
if not path.startswith("/events"): # the long poll would be noise
|
|
22
|
+
log.debug(
|
|
23
|
+
"%s %s -> %s (%.0f ms)", method, path, r.status,
|
|
24
|
+
(time.perf_counter() - started) * 1000
|
|
25
|
+
)
|
|
26
|
+
return data
|
|
16
27
|
except aiohttp.ClientResponseError as e:
|
|
17
28
|
try:
|
|
18
29
|
err = await e.history[-1].json() if e.history else {}
|
|
@@ -25,8 +36,12 @@ async def api_push(session: aiohttp.ClientSession, method: str, path: str, body=
|
|
|
25
36
|
wait = int(e.headers.get("Retry-After") or 0) if e.status == 429 or busy else 0
|
|
26
37
|
|
|
27
38
|
if 0 < wait <= 60 and tries > 1:
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
delay = wait * (1 + random.random() * 0.5)
|
|
40
|
+
log.warning(
|
|
41
|
+
"%s on %s %s, retrying in %.1fs",
|
|
42
|
+
"Server busy" if busy else "Rate limited", method, path, delay
|
|
43
|
+
)
|
|
44
|
+
await asyncio.sleep(delay)
|
|
30
45
|
return await api_push(session, method, path, body, tries - 1)
|
|
31
46
|
|
|
32
47
|
raise RuntimeError(f"{method} {path} failed ({e.status}): {msg or e.message}") from None
|
yappy/app.py
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
from .core import *
|
|
2
2
|
from .consts import BASE_URL
|
|
3
3
|
from .api import *
|
|
4
|
-
from .errors import
|
|
4
|
+
from .errors import (
|
|
5
|
+
CommandError, CommandInvokeError, ImproperToken, InvalidSignature,
|
|
6
|
+
UnknownCommand,
|
|
7
|
+
)
|
|
8
|
+
from .logger import setup_logging
|
|
5
9
|
from .parser import parser
|
|
6
10
|
|
|
7
11
|
import inspect
|
|
8
12
|
import asyncio
|
|
9
13
|
import aiohttp
|
|
10
|
-
import
|
|
14
|
+
import logging
|
|
11
15
|
from functools import wraps
|
|
12
16
|
from typing import Callable
|
|
13
17
|
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
14
20
|
class App:
|
|
15
21
|
def __init__(
|
|
16
22
|
self, verbose=False,
|
|
17
|
-
prefix = "!"
|
|
23
|
+
prefix = "!",
|
|
24
|
+
configure_logging = True
|
|
18
25
|
):
|
|
19
26
|
self.__commands = {}
|
|
20
27
|
self.__event_handlers = {
|
|
@@ -28,12 +35,30 @@ class App:
|
|
|
28
35
|
self._token = None
|
|
29
36
|
|
|
30
37
|
self._verbose = verbose
|
|
38
|
+
self._configure_logging = configure_logging
|
|
31
39
|
|
|
32
40
|
self.__cache = {
|
|
33
41
|
"users":{}, "channels":{},
|
|
34
42
|
"guilds": {}, "roles": {}
|
|
35
43
|
}
|
|
36
44
|
|
|
45
|
+
# in-flight lookups, so concurrent events share one request
|
|
46
|
+
self.__inflight = {}
|
|
47
|
+
# running event-handler tasks (kept so they aren't garbage collected)
|
|
48
|
+
self.__tasks = set()
|
|
49
|
+
|
|
50
|
+
def _once(self, key, fn, *args):
|
|
51
|
+
"""Run ``fn(*args)`` once per ``key`` at a time; concurrent callers
|
|
52
|
+
with the same key all wait on the same request."""
|
|
53
|
+
task = self.__inflight.get(key)
|
|
54
|
+
if task is None:
|
|
55
|
+
task = asyncio.ensure_future(fn(*args))
|
|
56
|
+
self.__inflight[key] = task
|
|
57
|
+
task.add_done_callback(
|
|
58
|
+
lambda _t, k=key: self.__inflight.pop(k, None)
|
|
59
|
+
)
|
|
60
|
+
return asyncio.shield(task)
|
|
61
|
+
|
|
37
62
|
async def _load_bot_info(self):
|
|
38
63
|
my_info = await API.get(self.session, "/me")
|
|
39
64
|
|
|
@@ -52,16 +77,10 @@ class App:
|
|
|
52
77
|
is_bot=True
|
|
53
78
|
)
|
|
54
79
|
|
|
55
|
-
|
|
56
|
-
self, id
|
|
57
|
-
):
|
|
58
|
-
_members_data = (await API.get(
|
|
59
|
-
self.session, f"/servers/{id}/members"
|
|
60
|
-
))["members"]
|
|
61
|
-
|
|
80
|
+
def _build_members(self, data):
|
|
62
81
|
members = []
|
|
63
82
|
|
|
64
|
-
for _user_data in
|
|
83
|
+
for _user_data in data["members"]:
|
|
65
84
|
roles = [
|
|
66
85
|
self.__cache["roles"][role_id]
|
|
67
86
|
for role_id in _user_data["roles"]
|
|
@@ -72,7 +91,7 @@ class App:
|
|
|
72
91
|
Member(
|
|
73
92
|
bot=self,
|
|
74
93
|
guild=None, roles=roles,
|
|
75
|
-
id=id,
|
|
94
|
+
id=_user_data.get("id", _user_data.get("user_id")),
|
|
76
95
|
name = _user_data["username"],
|
|
77
96
|
display_name = _user_data["display_name"],
|
|
78
97
|
badges = _user_data["badges"],
|
|
@@ -87,13 +106,7 @@ class App:
|
|
|
87
106
|
|
|
88
107
|
return members
|
|
89
108
|
|
|
90
|
-
|
|
91
|
-
self, id
|
|
92
|
-
):
|
|
93
|
-
_roles_data = (await API.get(
|
|
94
|
-
self.session, f"/servers/{id}/roles"
|
|
95
|
-
))
|
|
96
|
-
|
|
109
|
+
def _build_roles(self, id, _roles_data):
|
|
97
110
|
roles = []
|
|
98
111
|
|
|
99
112
|
for role in _roles_data["roles"]:
|
|
@@ -120,20 +133,37 @@ class App:
|
|
|
120
133
|
async def fetch_guild(
|
|
121
134
|
self, id
|
|
122
135
|
) -> Guild:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
136
|
+
# The server, roles and members endpoints don't depend on each other,
|
|
137
|
+
# so request them together (one round trip instead of three), and
|
|
138
|
+
# look up the owner as soon as the server payload is in.
|
|
139
|
+
server_t = asyncio.ensure_future(
|
|
140
|
+
API.get(self.session, f"/servers/{id}")
|
|
141
|
+
)
|
|
142
|
+
roles_t = asyncio.ensure_future(
|
|
143
|
+
API.get(self.session, f"/servers/{id}/roles")
|
|
144
|
+
)
|
|
145
|
+
members_t = asyncio.ensure_future(
|
|
146
|
+
API.get(self.session, f"/servers/{id}/members")
|
|
147
|
+
)
|
|
148
|
+
try:
|
|
149
|
+
_guild_data = (await server_t)["server"]
|
|
150
|
+
owner, roles_data, members_data = await asyncio.gather(
|
|
151
|
+
self.find_user(_guild_data["owner_id"]),
|
|
152
|
+
roles_t, members_t
|
|
153
|
+
)
|
|
154
|
+
except BaseException:
|
|
155
|
+
for t in (server_t, roles_t, members_t):
|
|
156
|
+
t.cancel()
|
|
157
|
+
raise
|
|
126
158
|
|
|
127
|
-
roles =
|
|
128
|
-
members =
|
|
159
|
+
roles = self._build_roles(id, roles_data)
|
|
160
|
+
members = self._build_members(members_data)
|
|
129
161
|
|
|
130
162
|
return Guild(
|
|
131
163
|
self,
|
|
132
164
|
id = id,
|
|
133
165
|
name=_guild_data["name"],
|
|
134
|
-
owner=
|
|
135
|
-
_guild_data["owner_id"]
|
|
136
|
-
),
|
|
166
|
+
owner=owner,
|
|
137
167
|
member_count = _guild_data["member_count"],
|
|
138
168
|
discoverable = _guild_data["discoverable"],
|
|
139
169
|
created_at = _guild_data["created_at"],
|
|
@@ -149,8 +179,12 @@ class App:
|
|
|
149
179
|
):
|
|
150
180
|
guild = self.get_guild(id)
|
|
151
181
|
if guild is None:
|
|
152
|
-
guild = await self.
|
|
153
|
-
|
|
182
|
+
guild = await self._once(("guild", id), self._load_guild, id)
|
|
183
|
+
return guild
|
|
184
|
+
|
|
185
|
+
async def _load_guild(self, id):
|
|
186
|
+
guild = await self.fetch_guild(id)
|
|
187
|
+
self.__cache["guilds"][id] = guild
|
|
154
188
|
return guild
|
|
155
189
|
|
|
156
190
|
def get_guild(
|
|
@@ -184,8 +218,12 @@ class App:
|
|
|
184
218
|
):
|
|
185
219
|
channel = self.get_channel(id)
|
|
186
220
|
if channel is None:
|
|
187
|
-
channel = await self.
|
|
188
|
-
|
|
221
|
+
channel = await self._once(("channel", id), self._load_channel, id)
|
|
222
|
+
return channel
|
|
223
|
+
|
|
224
|
+
async def _load_channel(self, id):
|
|
225
|
+
channel = await self.fetch_channel(id)
|
|
226
|
+
self.__cache["channels"][id] = channel
|
|
189
227
|
return channel
|
|
190
228
|
|
|
191
229
|
def get_channel(
|
|
@@ -223,11 +261,12 @@ class App:
|
|
|
223
261
|
user = self.get_user(id)
|
|
224
262
|
|
|
225
263
|
if user is None:
|
|
226
|
-
user = await self.
|
|
227
|
-
|
|
228
|
-
)
|
|
264
|
+
user = await self._once(("user", id), self._load_user, id)
|
|
265
|
+
return user
|
|
229
266
|
|
|
230
|
-
|
|
267
|
+
async def _load_user(self, id):
|
|
268
|
+
user = await self.fetch_user(id)
|
|
269
|
+
self.__cache["users"][id] = user
|
|
231
270
|
return user
|
|
232
271
|
|
|
233
272
|
def get_user(
|
|
@@ -256,45 +295,96 @@ class App:
|
|
|
256
295
|
channel=channel
|
|
257
296
|
)
|
|
258
297
|
|
|
259
|
-
async def _parse_arguments(self, content):
|
|
260
|
-
return await parser.parse_args(self, content)
|
|
298
|
+
async def _parse_arguments(self, content, command=None):
|
|
299
|
+
return await parser.parse_args(self, content, command)
|
|
261
300
|
|
|
262
301
|
async def _handle_prefix_command(self, ctx):
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
302
|
+
content = ctx.message.content
|
|
303
|
+
if not content.startswith(self.prefix):
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
parts = content[len(self.prefix):].split(None, 1)
|
|
307
|
+
if not parts:
|
|
308
|
+
return False # nothing but the prefix
|
|
309
|
+
name = parts[0].lower()
|
|
310
|
+
raw = parts[1] if len(parts) > 1 else ""
|
|
311
|
+
|
|
312
|
+
cmd = self.__commands.get(name) or self.__aliases.get(name)
|
|
313
|
+
if cmd is None:
|
|
314
|
+
raise UnknownCommand(f"Unknown command '{name}'.")
|
|
315
|
+
|
|
316
|
+
# Arguments are parsed per the command's signature, so this is also
|
|
317
|
+
# where bad types are caught (BadArgument) and, right after, wrong
|
|
318
|
+
# counts (MissingArgument / TooManyArguments).
|
|
319
|
+
arguments = await self._parse_arguments(raw, cmd)
|
|
320
|
+
cmd.check_arguments(arguments)
|
|
321
|
+
|
|
322
|
+
log.debug(
|
|
323
|
+
"Command '%s' invoked by %s",
|
|
324
|
+
cmd.name, getattr(ctx.message.author, "name", "?")
|
|
325
|
+
)
|
|
326
|
+
try:
|
|
327
|
+
await cmd.func(ctx, *arguments)
|
|
328
|
+
except CommandError:
|
|
329
|
+
raise
|
|
330
|
+
except Exception as e:
|
|
331
|
+
raise CommandInvokeError(e, command=cmd) from e
|
|
272
332
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
async def process_command(self, ctx):
|
|
336
|
+
"""Run the command in ``ctx.message`` if it is one.
|
|
337
|
+
|
|
338
|
+
Returns whether a command ran. Problems (unknown command, wrong
|
|
339
|
+
arguments, an exception inside the command) don't propagate: they go
|
|
340
|
+
to the ``command_error`` event handler if you registered one, or are
|
|
341
|
+
handled by :meth:`_default_command_error` otherwise.
|
|
342
|
+
"""
|
|
343
|
+
try:
|
|
344
|
+
return await self._handle_prefix_command(ctx)
|
|
345
|
+
except CommandError as e:
|
|
346
|
+
await self._on_command_error(ctx, e)
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
async def _on_command_error(self, ctx, error):
|
|
350
|
+
handler = self.__event_handlers.get("command_error")
|
|
351
|
+
if handler is None:
|
|
352
|
+
await self._default_command_error(ctx, error)
|
|
353
|
+
return
|
|
354
|
+
try:
|
|
355
|
+
await handler(ctx, error)
|
|
356
|
+
except Exception:
|
|
357
|
+
log.exception("Exception in the 'command_error' handler")
|
|
358
|
+
|
|
359
|
+
async def _default_command_error(self, ctx, error):
|
|
360
|
+
if isinstance(error, UnknownCommand):
|
|
361
|
+
# people type "!!!" or "!lol" all the time; don't answer those
|
|
362
|
+
log.debug("%s", error)
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
if isinstance(error, CommandInvokeError):
|
|
366
|
+
cmd = error.command
|
|
367
|
+
log.error(
|
|
368
|
+
"Command '%s' failed", cmd.name if cmd else "?",
|
|
369
|
+
exc_info=error.original
|
|
285
370
|
)
|
|
371
|
+
return
|
|
286
372
|
|
|
287
|
-
|
|
288
|
-
|
|
373
|
+
# The person used the command wrong, so tell them how to use it.
|
|
374
|
+
author = getattr(ctx.message.author, "name", "?")
|
|
375
|
+
log.info("%s: %s (from %s)", type(error).__name__, error, author)
|
|
289
376
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
377
|
+
reply = str(error)
|
|
378
|
+
if error.command is not None:
|
|
379
|
+
reply += f"\nUsage: `{error.command.usage(self.prefix)}`"
|
|
380
|
+
try:
|
|
381
|
+
await ctx.reply(reply)
|
|
382
|
+
except Exception:
|
|
383
|
+
log.exception("Couldn't send the command error message")
|
|
294
384
|
|
|
295
385
|
async def on_message(self, ctx):
|
|
296
386
|
if ctx.is_bot: return
|
|
297
|
-
await process_command(ctx)
|
|
387
|
+
await self.process_command(ctx)
|
|
298
388
|
|
|
299
389
|
async def _handle_event(self, session: aiohttp.ClientSession, ev):
|
|
300
390
|
d = ev["data"]
|
|
@@ -306,6 +396,13 @@ class App:
|
|
|
306
396
|
)
|
|
307
397
|
)
|
|
308
398
|
|
|
399
|
+
async def _dispatch(self, ev):
|
|
400
|
+
log.debug("Event '%s' (id %s)", ev["type"], ev["id"])
|
|
401
|
+
try:
|
|
402
|
+
await self._handle_event(self.session, ev)
|
|
403
|
+
except Exception:
|
|
404
|
+
log.exception("Unhandled exception in event '%s'", ev["type"])
|
|
405
|
+
|
|
309
406
|
async def _init_commands(self):
|
|
310
407
|
_commands = []
|
|
311
408
|
for name, func in self.__commands.items():
|
|
@@ -318,12 +415,17 @@ class App:
|
|
|
318
415
|
"/commands",
|
|
319
416
|
{"commands": _commands}
|
|
320
417
|
)
|
|
418
|
+
log.info(
|
|
419
|
+
"Registered %d command(s): %s",
|
|
420
|
+
len(_commands), ", ".join(c["name"] for c in _commands) or "-"
|
|
421
|
+
)
|
|
321
422
|
|
|
322
423
|
async def _event_loop(self):
|
|
323
424
|
async with aiohttp.ClientSession() as self.session:
|
|
324
425
|
self.session._bearer_token=self._token
|
|
325
426
|
|
|
326
427
|
self.user = await self._load_bot_info()
|
|
428
|
+
log.info("Logged in as %s (id %s)", self.user.name, self.user.id)
|
|
327
429
|
await self._init_commands()
|
|
328
430
|
|
|
329
431
|
events_init = await API.get(self.session, "/events?after=0&wait=0")
|
|
@@ -333,37 +435,58 @@ class App:
|
|
|
333
435
|
if "ready" in self.__event_handlers:
|
|
334
436
|
await self.__event_handlers["ready"]()
|
|
335
437
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
out = await API.get(self.session, f"/events?after={last}&wait=25")
|
|
339
|
-
fails = 0
|
|
340
|
-
except Exception as e:
|
|
341
|
-
fails += 1
|
|
342
|
-
if "(401)" in str(e):
|
|
343
|
-
raise ImproperToken("Please check if your token is correctly used!")
|
|
344
|
-
wait = min(30, 2 ** fails)
|
|
345
|
-
|
|
346
|
-
print(f"retrying in {wait}s:", e)
|
|
347
|
-
|
|
348
|
-
await asyncio.sleep(wait)
|
|
349
|
-
continue
|
|
350
|
-
for ev in out["events"]:
|
|
351
|
-
if ev["id"] <= last:
|
|
352
|
-
continue
|
|
353
|
-
last = ev["id"]
|
|
438
|
+
try:
|
|
439
|
+
while True:
|
|
354
440
|
try:
|
|
355
|
-
await
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
441
|
+
out = await API.get(self.session, f"/events?after={last}&wait=25")
|
|
442
|
+
if fails:
|
|
443
|
+
log.info("Connection to Yapper restored")
|
|
444
|
+
fails = 0
|
|
445
|
+
except Exception as e:
|
|
446
|
+
fails += 1
|
|
447
|
+
if "(401)" in str(e):
|
|
448
|
+
raise ImproperToken("Please check if your token is correctly used!")
|
|
449
|
+
wait = min(30, 2 ** fails)
|
|
450
|
+
|
|
451
|
+
log.warning("Problem reaching Yapper: %s. Retrying in %ss", e, wait)
|
|
452
|
+
|
|
453
|
+
await asyncio.sleep(wait)
|
|
454
|
+
continue
|
|
455
|
+
for ev in out["events"]:
|
|
456
|
+
if ev["id"] <= last:
|
|
457
|
+
continue
|
|
458
|
+
last = ev["id"]
|
|
459
|
+
# Run each event in its own task so one slow handler
|
|
460
|
+
# doesn't hold up the others or the next poll.
|
|
461
|
+
task = asyncio.create_task(self._dispatch(ev))
|
|
462
|
+
self.__tasks.add(task)
|
|
463
|
+
task.add_done_callback(self.__tasks.discard)
|
|
464
|
+
finally:
|
|
465
|
+
# let in-flight handlers finish cancelling before the
|
|
466
|
+
# session is closed underneath them
|
|
467
|
+
tasks = list(self.__tasks)
|
|
468
|
+
for task in tasks:
|
|
469
|
+
task.cancel()
|
|
470
|
+
if tasks:
|
|
471
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
472
|
+
|
|
359
473
|
|
|
360
474
|
def event(self, event_type: str):
|
|
475
|
+
"""Register a coroutine as the handler for ``event_type``.
|
|
476
|
+
|
|
477
|
+
Besides Yapper's own events (``"message"``, ``"ready"``) there is
|
|
478
|
+
``"command_error"``: ``async def handler(ctx, error)``, called when a
|
|
479
|
+
command is unknown, given bad arguments, or raises. ``error`` is a
|
|
480
|
+
:class:`yappy.errors.CommandError`. Registering it replaces the
|
|
481
|
+
default behaviour (replying with the problem and a usage line).
|
|
482
|
+
"""
|
|
361
483
|
def wrapper(func):
|
|
362
484
|
if not inspect.iscoroutinefunction(func):
|
|
363
485
|
raise InvalidSignature(f"Expected event handler for '{event_type}' to be a coroutine!")
|
|
364
486
|
self.__event_handlers[
|
|
365
487
|
event_type
|
|
366
488
|
] = func
|
|
489
|
+
return func
|
|
367
490
|
return wrapper
|
|
368
491
|
|
|
369
492
|
def command(
|
|
@@ -386,16 +509,19 @@ class App:
|
|
|
386
509
|
|
|
387
510
|
for alias in aliases:
|
|
388
511
|
self.__aliases[alias.lower()] = self.__commands[name.lower()]
|
|
512
|
+
return func
|
|
389
513
|
return wrapper
|
|
390
514
|
|
|
391
515
|
def run(self, token: str):
|
|
392
516
|
self._token = token
|
|
393
517
|
|
|
394
|
-
self.
|
|
395
|
-
|
|
518
|
+
if self._configure_logging:
|
|
519
|
+
setup_logging(verbose=self._verbose)
|
|
396
520
|
|
|
521
|
+
# asyncio.run turns Ctrl+C into a cancellation of the main task, so
|
|
522
|
+
# _event_loop unwinds normally and the HTTP session gets closed.
|
|
397
523
|
try:
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
524
|
+
asyncio.run(self._event_loop())
|
|
525
|
+
except KeyboardInterrupt:
|
|
526
|
+
log.info("Shutting down")
|
|
401
527
|
__all__=["App"]
|
yappy/core.py
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
from .api import *
|
|
2
2
|
from typing import Callable
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
import typing
|
|
3
6
|
import urllib.parse
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from .errors import InvalidSignature, MissingArgument, TooManyArguments
|
|
4
9
|
from .utils import get_asset_url
|
|
5
10
|
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
6
13
|
class Assets:
|
|
7
14
|
def __init__(self,
|
|
8
15
|
avatar: str = "",
|
|
@@ -39,6 +46,13 @@ class Bot(User):
|
|
|
39
46
|
super().__init__(**kwargs)
|
|
40
47
|
self.owner_id = owner_id
|
|
41
48
|
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class CommandParam:
|
|
51
|
+
"""One argument a command accepts (not counting ``ctx``)."""
|
|
52
|
+
name: str
|
|
53
|
+
annotation: object = inspect.Parameter.empty
|
|
54
|
+
required: bool = True
|
|
55
|
+
|
|
42
56
|
class Command:
|
|
43
57
|
def __init__(self,
|
|
44
58
|
name: str,
|
|
@@ -51,6 +65,65 @@ class Command:
|
|
|
51
65
|
self.description = description
|
|
52
66
|
self.func = func
|
|
53
67
|
|
|
68
|
+
self.params: list[CommandParam] = []
|
|
69
|
+
self.var_positional: CommandParam | None = None
|
|
70
|
+
if func is not None:
|
|
71
|
+
self._inspect_signature()
|
|
72
|
+
|
|
73
|
+
def _inspect_signature(self):
|
|
74
|
+
P = inspect.Parameter
|
|
75
|
+
params = list(inspect.signature(self.func).parameters.values())
|
|
76
|
+
|
|
77
|
+
if not params or params[0].kind not in (
|
|
78
|
+
P.POSITIONAL_ONLY, P.POSITIONAL_OR_KEYWORD
|
|
79
|
+
):
|
|
80
|
+
raise InvalidSignature(
|
|
81
|
+
f"Command '{self.name}' must accept 'ctx' as its first argument!"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
hints = typing.get_type_hints(self.func)
|
|
86
|
+
except Exception:
|
|
87
|
+
# unresolvable annotations: treat them as "no annotation"
|
|
88
|
+
hints = {}
|
|
89
|
+
|
|
90
|
+
for p in params[1:]:
|
|
91
|
+
ann = hints.get(p.name, p.annotation)
|
|
92
|
+
if p.kind in (P.POSITIONAL_ONLY, P.POSITIONAL_OR_KEYWORD):
|
|
93
|
+
self.params.append(
|
|
94
|
+
CommandParam(p.name, ann, p.default is P.empty)
|
|
95
|
+
)
|
|
96
|
+
elif p.kind is P.VAR_POSITIONAL:
|
|
97
|
+
self.var_positional = CommandParam(p.name, ann, False)
|
|
98
|
+
elif p.kind is P.KEYWORD_ONLY and p.default is P.empty:
|
|
99
|
+
raise InvalidSignature(
|
|
100
|
+
f"Command '{self.name}' has a required keyword-only "
|
|
101
|
+
f"argument '{p.name}', which can't be filled from chat!"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def param_for(self, index: int) -> CommandParam | None:
|
|
105
|
+
"""The parameter that the argument at ``index`` is bound to."""
|
|
106
|
+
if index < len(self.params):
|
|
107
|
+
return self.params[index]
|
|
108
|
+
return self.var_positional
|
|
109
|
+
|
|
110
|
+
def check_arguments(self, args: list):
|
|
111
|
+
"""Raise if ``args`` doesn't fit this command's parameters."""
|
|
112
|
+
required = sum(1 for p in self.params if p.required)
|
|
113
|
+
if len(args) < required:
|
|
114
|
+
raise MissingArgument(self.params[len(args)].name, command=self)
|
|
115
|
+
if self.var_positional is None and len(args) > len(self.params):
|
|
116
|
+
raise TooManyArguments(len(self.params), len(args), command=self)
|
|
117
|
+
|
|
118
|
+
def usage(self, prefix: str = "") -> str:
|
|
119
|
+
parts = [
|
|
120
|
+
f"<{p.name}>" if p.required else f"[{p.name}]"
|
|
121
|
+
for p in self.params
|
|
122
|
+
]
|
|
123
|
+
if self.var_positional is not None:
|
|
124
|
+
parts.append(f"[{self.var_positional.name}...]")
|
|
125
|
+
return " ".join([f"{prefix}{self.name}", *parts])
|
|
126
|
+
|
|
54
127
|
class Permissions:
|
|
55
128
|
def __init__(self, **perms):
|
|
56
129
|
self._perms = perms
|
|
@@ -104,42 +177,50 @@ class Guild:
|
|
|
104
177
|
}}
|
|
105
178
|
|
|
106
179
|
for m in members:
|
|
107
|
-
|
|
180
|
+
if m.id is not None:
|
|
181
|
+
self._cache["members"][m.id] = m
|
|
108
182
|
m. guild = self
|
|
109
183
|
async def find_member(self, mid):
|
|
110
184
|
member = self.get_member(mid)
|
|
111
185
|
if member is None:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
))["member"]
|
|
116
|
-
|
|
117
|
-
roles = [
|
|
118
|
-
self._cache["roles"][role_id]
|
|
119
|
-
for role_id in _user_data["roles"]
|
|
120
|
-
if role_id in self._cache["roles"]
|
|
121
|
-
]
|
|
122
|
-
|
|
123
|
-
member = Member(
|
|
124
|
-
bot=self.bot,
|
|
125
|
-
guild=self, roles=roles,
|
|
126
|
-
id=id,
|
|
127
|
-
name = _user_data["username"],
|
|
128
|
-
display_name = _user_data["display_name"],
|
|
129
|
-
badges = _user_data["badges"],
|
|
130
|
-
joined_at = _user_data["joined_at"],
|
|
131
|
-
nickname = _user_data["nickname"],
|
|
132
|
-
assets = Assets(
|
|
133
|
-
avatar = "https://files.yapper.website/avatars/"+_user_data.get("avatar_path",""),
|
|
134
|
-
banner = "https://files.yapper.website/banners/"+_user_data.get("banner_path", "")
|
|
135
|
-
),
|
|
186
|
+
# concurrent lookups of the same member share one request
|
|
187
|
+
member = await self.bot._once(
|
|
188
|
+
("member", self.id, mid), self._load_member, mid
|
|
136
189
|
)
|
|
190
|
+
return member
|
|
191
|
+
|
|
192
|
+
async def _load_member(self, mid):
|
|
193
|
+
_user_data = (await API.get(
|
|
194
|
+
self.bot.session,
|
|
195
|
+
f"/servers/{self.id}/members/{mid}"
|
|
196
|
+
))["member"]
|
|
137
197
|
|
|
138
|
-
|
|
139
|
-
|
|
198
|
+
roles = [
|
|
199
|
+
self._cache["roles"][role_id]
|
|
200
|
+
for role_id in _user_data["roles"]
|
|
201
|
+
if role_id in self._cache["roles"]
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
member = Member(
|
|
205
|
+
bot=self.bot,
|
|
206
|
+
guild=self, roles=roles,
|
|
207
|
+
id=mid,
|
|
208
|
+
name = _user_data["username"],
|
|
209
|
+
display_name = _user_data["display_name"],
|
|
210
|
+
badges = _user_data["badges"],
|
|
211
|
+
joined_at = _user_data["joined_at"],
|
|
212
|
+
nickname = _user_data["nickname"],
|
|
213
|
+
assets = Assets(
|
|
214
|
+
avatar = "https://files.yapper.website/avatars/"+_user_data.get("avatar_path",""),
|
|
215
|
+
banner = "https://files.yapper.website/banners/"+_user_data.get("banner_path", "")
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
self._cache["members"][mid] = member
|
|
140
220
|
return member
|
|
221
|
+
|
|
141
222
|
def get_member(self, mid):
|
|
142
|
-
return self._cache.get(mid, None)
|
|
223
|
+
return self._cache["members"].get(mid, None)
|
|
143
224
|
|
|
144
225
|
class Channel:
|
|
145
226
|
def __init__(self,
|
yappy/errors.py
CHANGED
|
@@ -1,8 +1,79 @@
|
|
|
1
|
-
class
|
|
1
|
+
class YappyError(Exception):
|
|
2
|
+
"""Base class for all YapPy errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ImproperToken(YappyError):
|
|
2
6
|
pass
|
|
3
7
|
|
|
4
|
-
|
|
8
|
+
|
|
9
|
+
class InvalidSignature(YappyError):
|
|
5
10
|
pass
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CommandError(YappyError):
|
|
14
|
+
"""Base class for errors raised while handling a command.
|
|
15
|
+
|
|
16
|
+
``str(error)`` is a message that is safe to show to the person who ran
|
|
17
|
+
the command. ``command`` is the :class:`~yappy.core.Command` involved
|
|
18
|
+
(``None`` if the command couldn't be found).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str = "", *, command=None):
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.command = command
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnknownCommand(CommandError):
|
|
8
27
|
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MissingArgument(CommandError):
|
|
31
|
+
"""A required argument was not given."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, param: str, *, command=None):
|
|
34
|
+
super().__init__(
|
|
35
|
+
f"Missing required argument '{param}'.", command=command
|
|
36
|
+
)
|
|
37
|
+
self.param = param
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TooManyArguments(CommandError):
|
|
41
|
+
"""More arguments were given than the command accepts."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, expected: int, given: int, *, command=None):
|
|
44
|
+
noun = "argument" if expected == 1 else "arguments"
|
|
45
|
+
super().__init__(
|
|
46
|
+
f"Too many arguments: expected at most {expected} {noun}, "
|
|
47
|
+
f"got {given}.",
|
|
48
|
+
command=command,
|
|
49
|
+
)
|
|
50
|
+
self.expected = expected
|
|
51
|
+
self.given = given
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BadArgument(CommandError):
|
|
55
|
+
"""An argument couldn't be converted to what the command expects."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, param: str, value, expected: str, *, command=None):
|
|
58
|
+
shown = str(value)
|
|
59
|
+
if len(shown) > 40:
|
|
60
|
+
shown = shown[:37] + "..."
|
|
61
|
+
super().__init__(
|
|
62
|
+
f"Invalid value {shown!r} for '{param}': expected {expected}.",
|
|
63
|
+
command=command,
|
|
64
|
+
)
|
|
65
|
+
self.param = param
|
|
66
|
+
self.value = value
|
|
67
|
+
self.expected = expected
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CommandInvokeError(CommandError):
|
|
71
|
+
"""The command itself raised an exception. The original is ``original``."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, original: BaseException, *, command=None):
|
|
74
|
+
name = command.name if command is not None else "?"
|
|
75
|
+
super().__init__(
|
|
76
|
+
f"Command '{name}' raised {type(original).__name__}: {original}",
|
|
77
|
+
command=command,
|
|
78
|
+
)
|
|
79
|
+
self.original = original
|
yappy/logger.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Logging for YapPy.
|
|
2
|
+
|
|
3
|
+
YapPy logs through the standard :mod:`logging` module under the ``"yappy"``
|
|
4
|
+
logger (``yappy.app``, ``yappy.api``, ...). :func:`setup_logging` attaches a
|
|
5
|
+
`Rich <https://rich.readthedocs.io>`_ handler so output is coloured and
|
|
6
|
+
exceptions are printed as readable tracebacks.
|
|
7
|
+
|
|
8
|
+
``App.run()`` calls it for you. To take over logging yourself, create the app
|
|
9
|
+
with ``App(configure_logging=False)`` and add your own handlers to
|
|
10
|
+
``logging.getLogger("yappy")``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
import aiohttp
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.logging import RichHandler
|
|
19
|
+
|
|
20
|
+
LOGGER_NAME = "yappy"
|
|
21
|
+
_MARKER = "_yappy_rich_handler"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def setup_logging(
|
|
25
|
+
level: int | str | None = None,
|
|
26
|
+
*,
|
|
27
|
+
verbose: bool = False,
|
|
28
|
+
console: Console | None = None,
|
|
29
|
+
show_locals: bool = False,
|
|
30
|
+
) -> logging.Logger:
|
|
31
|
+
"""Send YapPy's logs to the terminal using Rich.
|
|
32
|
+
|
|
33
|
+
:param level: a ``logging`` level. Defaults to ``DEBUG`` if ``verbose``
|
|
34
|
+
else ``INFO``.
|
|
35
|
+
:param verbose: also show debug output (events, HTTP calls, command
|
|
36
|
+
invocations) and prefix each line with the module it came from.
|
|
37
|
+
:param console: a ``rich.console.Console`` to print to.
|
|
38
|
+
:param show_locals: include local variables in exception tracebacks.
|
|
39
|
+
|
|
40
|
+
Safe to call more than once; it replaces the handler it added before.
|
|
41
|
+
"""
|
|
42
|
+
if level is None:
|
|
43
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
46
|
+
for handler in list(logger.handlers):
|
|
47
|
+
if getattr(handler, _MARKER, False):
|
|
48
|
+
logger.removeHandler(handler)
|
|
49
|
+
|
|
50
|
+
handler = RichHandler(
|
|
51
|
+
console=console,
|
|
52
|
+
show_path=False,
|
|
53
|
+
markup=False, # chat content in log messages must not be parsed as markup
|
|
54
|
+
rich_tracebacks=True,
|
|
55
|
+
tracebacks_show_locals=show_locals,
|
|
56
|
+
tracebacks_suppress=[aiohttp, asyncio],
|
|
57
|
+
log_time_format="[%H:%M:%S]",
|
|
58
|
+
omit_repeated_times=False,
|
|
59
|
+
)
|
|
60
|
+
handler.setFormatter(
|
|
61
|
+
logging.Formatter("%(name)s %(message)s" if verbose else "%(message)s")
|
|
62
|
+
)
|
|
63
|
+
setattr(handler, _MARKER, True)
|
|
64
|
+
|
|
65
|
+
logger.addHandler(handler)
|
|
66
|
+
logger.setLevel(level)
|
|
67
|
+
# we own the output; don't print every line a second time via the root logger
|
|
68
|
+
logger.propagate = False
|
|
69
|
+
return logger
|
yappy/parser.py
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
import math
|
|
1
4
|
import re
|
|
2
5
|
from typing import Any, List, Union
|
|
3
6
|
|
|
7
|
+
from .core import Channel, Role, User
|
|
8
|
+
from .errors import BadArgument
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# What a person sees when an argument doesn't fit the annotation.
|
|
13
|
+
_EXPECTED = {
|
|
14
|
+
int: "a whole number",
|
|
15
|
+
float: "a number",
|
|
16
|
+
bool: "true or false",
|
|
17
|
+
User: "a user mention like <@123>",
|
|
18
|
+
Channel: "a channel mention like <#123>",
|
|
19
|
+
Role: "a role mention like <@&123>",
|
|
20
|
+
}
|
|
21
|
+
_NOT_FOUND = {
|
|
22
|
+
User: "a user that exists",
|
|
23
|
+
Channel: "a channel that exists",
|
|
24
|
+
Role: "a role that exists",
|
|
25
|
+
}
|
|
26
|
+
_MODELS = (User, Channel, Role)
|
|
27
|
+
_INT = re.compile(r'^[+-]?[0-9]+$')
|
|
28
|
+
_TRUE = {"true", "yes", "y", "on", "1"}
|
|
29
|
+
_FALSE = {"false", "no", "n", "off", "0"}
|
|
30
|
+
|
|
31
|
+
|
|
4
32
|
class CommandParser:
|
|
5
33
|
def __init__(self):
|
|
6
34
|
self.token_regex = re.compile(
|
|
@@ -13,41 +41,98 @@ class CommandParser:
|
|
|
13
41
|
self.channel_regex = re.compile(r'^<#([0-9\-]+)>$|^<#(.+)>$')
|
|
14
42
|
self.role_regex = re.compile(r'^<@&([0-9\-]+)>$|^<@&(.+)>$')
|
|
15
43
|
|
|
16
|
-
async def parse_args(self, bot, text: str) -> List[Any]:
|
|
44
|
+
async def parse_args(self, bot, text: str, command=None) -> List[Any]:
|
|
45
|
+
"""Split ``text`` into arguments.
|
|
46
|
+
|
|
47
|
+
When ``command`` is given, each argument is converted according to
|
|
48
|
+
the annotation of the parameter it will be passed to (``str``,
|
|
49
|
+
``int``, ``float``, ``bool``, ``User``, ``Channel`` or ``Role``) and
|
|
50
|
+
a :class:`~yappy.errors.BadArgument` is raised if it doesn't fit.
|
|
51
|
+
Without annotations the type is guessed, as before.
|
|
52
|
+
"""
|
|
17
53
|
parsed_arguments = []
|
|
18
|
-
|
|
19
|
-
for match in self.token_regex.finditer(text):
|
|
54
|
+
|
|
55
|
+
for index, match in enumerate(self.token_regex.finditer(text)):
|
|
20
56
|
dq_str, sq_str, _raw, unquoted = match.groups()
|
|
21
|
-
|
|
57
|
+
|
|
22
58
|
if dq_str is not None:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
59
|
+
kind, token = "quoted", dq_str.encode().decode('unicode_escape')
|
|
60
|
+
elif sq_str is not None:
|
|
61
|
+
kind, token = "quoted", sq_str.encode().decode('unicode_escape')
|
|
62
|
+
elif _raw is not None:
|
|
63
|
+
kind, token = "mention", _raw
|
|
64
|
+
else:
|
|
65
|
+
kind, token = "bare", unquoted
|
|
66
|
+
|
|
67
|
+
param = command.param_for(index) if command is not None else None
|
|
68
|
+
parsed_arguments.append(
|
|
69
|
+
await self._convert(bot, kind, token, param, index, command)
|
|
70
|
+
)
|
|
71
|
+
|
|
36
72
|
return parsed_arguments
|
|
37
73
|
|
|
74
|
+
async def _convert(self, bot, kind, token, param, index, command):
|
|
75
|
+
hint = param.annotation if param is not None else inspect.Parameter.empty
|
|
76
|
+
name = param.name if param is not None else f"argument {index + 1}"
|
|
77
|
+
|
|
78
|
+
def bad(expected):
|
|
79
|
+
return BadArgument(name, token, expected, command=command)
|
|
80
|
+
|
|
81
|
+
if hint is str:
|
|
82
|
+
return token
|
|
83
|
+
|
|
84
|
+
if hint is int:
|
|
85
|
+
if kind == "mention" or not _INT.match(token):
|
|
86
|
+
raise bad(_EXPECTED[int])
|
|
87
|
+
return int(token)
|
|
88
|
+
|
|
89
|
+
if hint is float:
|
|
90
|
+
try:
|
|
91
|
+
value = float(token) if kind != "mention" else math.nan
|
|
92
|
+
except ValueError:
|
|
93
|
+
value = math.nan
|
|
94
|
+
if not math.isfinite(value):
|
|
95
|
+
raise bad(_EXPECTED[float])
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
if hint is bool:
|
|
99
|
+
word = token.lower()
|
|
100
|
+
if kind != "mention" and word in _TRUE:
|
|
101
|
+
return True
|
|
102
|
+
if kind != "mention" and word in _FALSE:
|
|
103
|
+
return False
|
|
104
|
+
raise bad(_EXPECTED[bool])
|
|
105
|
+
|
|
106
|
+
# No annotation (or one we don't convert): same behaviour as before.
|
|
107
|
+
if kind == "quoted":
|
|
108
|
+
value = token
|
|
109
|
+
elif kind == "mention":
|
|
110
|
+
try:
|
|
111
|
+
value = await self._process_mentions(bot, token)
|
|
112
|
+
except RuntimeError as e:
|
|
113
|
+
# the API said no (404 etc.); don't leak its message to chat
|
|
114
|
+
log.debug("Couldn't resolve mention %r: %s", token, e)
|
|
115
|
+
raise bad(_NOT_FOUND.get(hint, "a mention of something that exists"))
|
|
116
|
+
else:
|
|
117
|
+
value = self._parse_primitive(token)
|
|
118
|
+
|
|
119
|
+
if hint in _MODELS and not isinstance(value, hint):
|
|
120
|
+
raise bad(_EXPECTED[hint])
|
|
121
|
+
return value
|
|
122
|
+
|
|
38
123
|
async def _process_mentions(self, bot, token: str) -> Any:
|
|
39
124
|
if user_match := self.user_regex.match(token):
|
|
40
125
|
user_id = user_match.group(1) or user_match.group(2)
|
|
41
126
|
return await self._parse_user(bot, user_id)
|
|
42
|
-
|
|
127
|
+
|
|
43
128
|
if channel_match := self.channel_regex.match(token):
|
|
44
129
|
channel_id = channel_match.group(1) or channel_match.group(2)
|
|
45
130
|
return await self._parse_channel(bot, channel_id)
|
|
46
|
-
|
|
131
|
+
|
|
47
132
|
if role_match := self.role_regex.match(token):
|
|
48
133
|
role_id = role_match.group(1) or role_match.group(2)
|
|
49
134
|
return await self._parse_role(bot, role_id)
|
|
50
|
-
|
|
135
|
+
|
|
51
136
|
return token
|
|
52
137
|
|
|
53
138
|
def _parse_primitive(self, token: str) -> Union[int, float, str]:
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
yappy/__init__.py,sha256=BZU8ktPMVc9XeJ5YJeeCAPCjRyaxYIU_zusvjiSi5_Q,21
|
|
2
|
-
yappy/api.py,sha256=_v1r9xPqrqmNwiz7ESnIsrRrzXNrm34eem7XUa4UAWU,2392
|
|
3
|
-
yappy/app.py,sha256=vT4MKygpFmZoc6yu8Uu16hxldNjt5cE2NB-EgFvhifc,11651
|
|
4
|
-
yappy/consts.py,sha256=0pIUIY1Ayo6_q3XhWr9hvKLgOT7TC9RgsjPZNVYy8QQ,51
|
|
5
|
-
yappy/core.py,sha256=quCOfJPjVzUbwdK2NeH3IN3Yr8sNOaQ6JZoMVtELPDk,8110
|
|
6
|
-
yappy/errors.py,sha256=BZ42Du8XWA_SY9tR5Ye8gVWWVKAvRDZZ8Grt_CoOOZs,133
|
|
7
|
-
yappy/parser.py,sha256=XgMXkXHyuL_-oH6rpsbQcnmO5KQrNADIT20GH4-C1LM,2583
|
|
8
|
-
yappy/utils.py,sha256=s7wmPTflqTAIgeAtf0a-gWgEfkQaZGKWYEqm2i4MsUM,129
|
|
9
|
-
python_yappy-0.1.1.dist-info/WHEEL,sha256=lrO5MD1WVAWzcbNy_L2BwtfPrcM3KfUFpbKYXLkJX4A,80
|
|
10
|
-
python_yappy-0.1.1.dist-info/METADATA,sha256=gNceBXMHD6CuuMQO07gR6nTMTINHeVP98VmGIG7la98,4663
|
|
11
|
-
python_yappy-0.1.1.dist-info/RECORD,,
|
|
File without changes
|