yapper-bot 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.
yapperpy/_http/aio.py ADDED
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import ssl
6
+ from urllib.parse import urlsplit
7
+
8
+ from .base import Headers, RawRequest, RawResponse, TransportError, TransportTimeout
9
+
10
+
11
+ class _AsyncConnection:
12
+ def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
13
+ self.reader = reader
14
+ self.writer = writer
15
+
16
+ def close(self) -> None:
17
+ with contextlib.suppress(Exception):
18
+ self.writer.close()
19
+
20
+
21
+ class HTTPAsyncTransport:
22
+ def __init__(
23
+ self,
24
+ *,
25
+ timeout: float = 40.0,
26
+ ssl_context: ssl.SSLContext | None = None,
27
+ max_idle: int = 4,
28
+ ) -> None:
29
+ self.timeout = timeout
30
+ self.ssl_context = ssl_context or ssl.create_default_context()
31
+ self.max_idle = max(1, max_idle)
32
+ self._pool: dict[tuple[str, int], list[_AsyncConnection]] = {}
33
+ self._lock = asyncio.Lock()
34
+
35
+ async def _checkout(self, host: str, port: int, secure: bool) -> _AsyncConnection:
36
+ async with self._lock:
37
+ idle = self._pool.get((host, port))
38
+ if idle:
39
+ return idle.pop()
40
+ if secure:
41
+ reader, writer = await asyncio.open_connection(
42
+ host, port, ssl=self.ssl_context, server_hostname=host
43
+ )
44
+ else:
45
+ reader, writer = await asyncio.open_connection(host, port)
46
+ return _AsyncConnection(reader, writer)
47
+
48
+ async def _checkin(self, host: str, port: int, connection: _AsyncConnection) -> None:
49
+ async with self._lock:
50
+ idle = self._pool.setdefault((host, port), [])
51
+ if len(idle) < self.max_idle:
52
+ idle.append(connection)
53
+ return
54
+ connection.close()
55
+
56
+ async def send(self, request: RawRequest) -> RawResponse:
57
+ try:
58
+ return await asyncio.wait_for(self._exchange(request), request.timeout or self.timeout)
59
+ except asyncio.TimeoutError as exc:
60
+ raise TransportTimeout("request timed out") from exc
61
+ except TransportError:
62
+ raise
63
+ except OSError as exc:
64
+ raise TransportError(str(exc) or type(exc).__name__) from exc
65
+
66
+ async def _exchange(self, request: RawRequest) -> RawResponse:
67
+ parts = urlsplit(request.url)
68
+ host = parts.hostname or ""
69
+ secure = parts.scheme != "http"
70
+ port = parts.port or (443 if secure else 80)
71
+ path = parts.path or "/"
72
+ if parts.query:
73
+ path = f"{path}?{parts.query}"
74
+ headers = dict(request.headers)
75
+ headers.setdefault("Accept", "application/json")
76
+ headers.setdefault("Connection", "keep-alive")
77
+ if request.body is not None:
78
+ headers.setdefault("Content-Length", str(len(request.body)))
79
+ connection = await self._checkout(host, port, secure)
80
+ try:
81
+ head = f"{request.method} {path} HTTP/1.1\r\n".encode("latin-1")
82
+ head += b"".join(
83
+ f"{name}: {value}\r\n".encode("latin-1") for name, value in headers.items()
84
+ )
85
+ head += b"\r\n"
86
+ connection.writer.write(head)
87
+ if request.body:
88
+ connection.writer.write(request.body)
89
+ await connection.writer.drain()
90
+ status, reason, response_headers, body = await self._read_response(
91
+ connection.reader, request.method
92
+ )
93
+ except (TransportError, OSError, asyncio.IncompleteReadError):
94
+ connection.close()
95
+ raise
96
+ if response_headers.get("connection", "").lower() == "close":
97
+ connection.close()
98
+ else:
99
+ await self._checkin(host, port, connection)
100
+ return RawResponse(status=status, reason=reason, headers=response_headers, body=body)
101
+
102
+ async def _read_response(
103
+ self, reader: asyncio.StreamReader, method: str = "GET"
104
+ ) -> tuple[int, str, Headers, bytes]:
105
+ status_line = await reader.readline()
106
+ if not status_line:
107
+ raise TransportError("the connection closed before a response arrived")
108
+ parts = status_line.decode("latin-1").rstrip("\r\n").split(" ", 2)
109
+ if len(parts) < 2:
110
+ raise TransportError("the server sent a malformed status line")
111
+ try:
112
+ status = int(parts[1])
113
+ except ValueError as exc:
114
+ raise TransportError("the server sent a malformed status line") from exc
115
+ reason = parts[2] if len(parts) > 2 else ""
116
+ headers: Headers = Headers()
117
+ while True:
118
+ line = await reader.readline()
119
+ if not line:
120
+ raise TransportError("the connection closed while reading headers")
121
+ if line in (b"\r\n", b"\n"):
122
+ break
123
+ name, _, value = line.decode("latin-1").partition(":")
124
+ headers[name.strip()] = value.strip()
125
+ body = await self._read_body(reader, headers, status, method)
126
+ return status, reason, headers, body
127
+
128
+ async def _read_body(
129
+ self,
130
+ reader: asyncio.StreamReader,
131
+ headers: Headers,
132
+ status: int = 200,
133
+ method: str = "GET",
134
+ ) -> bytes:
135
+ if status in (204, 304) or method.upper() == "HEAD":
136
+ return b""
137
+ if headers.get("transfer-encoding", "").lower() == "chunked":
138
+ return await self._read_chunked(reader)
139
+ raw_length = headers.get("content-length")
140
+ if raw_length is None:
141
+ return await reader.read()
142
+ try:
143
+ length = int(raw_length)
144
+ except ValueError as exc:
145
+ raise TransportError("the server sent a malformed content length") from exc
146
+ if length <= 0:
147
+ return b""
148
+ return await reader.readexactly(length)
149
+
150
+ async def _read_chunked(self, reader: asyncio.StreamReader) -> bytes:
151
+ chunks: list[bytes] = []
152
+ while True:
153
+ size_line = await reader.readline()
154
+ if not size_line:
155
+ raise TransportError("the connection closed while reading the body")
156
+ try:
157
+ size = int(size_line.split(b";")[0].strip(), 16)
158
+ except ValueError as exc:
159
+ raise TransportError("the server sent a malformed chunk size") from exc
160
+ if size == 0:
161
+ while True:
162
+ trailer = await reader.readline()
163
+ if trailer in (b"\r\n", b"\n", b""):
164
+ break
165
+ break
166
+ chunks.append(await reader.readexactly(size))
167
+ await reader.readexactly(2)
168
+ return b"".join(chunks)
169
+
170
+ async def close(self) -> None:
171
+ async with self._lock:
172
+ for idle in self._pool.values():
173
+ for connection in idle:
174
+ connection.close()
175
+ self._pool.clear()
yapperpy/_http/base.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Iterable, Mapping
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Protocol
7
+ from urllib.parse import parse_qsl, quote, urlencode, urlsplit
8
+
9
+ from .._version import __version__
10
+
11
+ DEFAULT_BASE_URL = "https://www.yapper.website/api/bot/v1"
12
+ DEFAULT_TIMEOUT = 40.0
13
+ USER_AGENT = f"yapperpy/{__version__} (+https://www.yapper.website/developers)"
14
+
15
+
16
+ def segment(value: object) -> str:
17
+ return quote(str(value), safe="")
18
+
19
+
20
+ def build_url(base: str, path: str, query: Mapping[str, Any] | None = None) -> str:
21
+ url = base.rstrip("/") + path
22
+ if query:
23
+ pairs = [(k, str(v)) for k, v in query.items() if v is not None]
24
+ if pairs:
25
+ url += "?" + urlencode(pairs)
26
+ return url
27
+
28
+
29
+ class TransportError(Exception):
30
+ pass
31
+
32
+
33
+ class TransportTimeout(TransportError):
34
+ pass
35
+
36
+
37
+ class Headers(dict[str, str]):
38
+ def __init__(self, data: Mapping[str, str] | Iterable[tuple[str, str]] | None = None) -> None:
39
+ super().__init__()
40
+ if data is None:
41
+ return
42
+ pairs = data.items() if isinstance(data, Mapping) else data
43
+ for name, value in pairs:
44
+ self[name] = value
45
+
46
+ def __setitem__(self, name: str, value: str) -> None:
47
+ super().__setitem__(name.lower(), value)
48
+
49
+ def __getitem__(self, name: str) -> str:
50
+ return super().__getitem__(name.lower())
51
+
52
+ def __contains__(self, name: object) -> bool:
53
+ if isinstance(name, str):
54
+ return super().__contains__(name.lower())
55
+ return super().__contains__(name)
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class RawRequest:
60
+ method: str
61
+ url: str
62
+ headers: dict[str, str] = field(default_factory=dict)
63
+ body: bytes | None = None
64
+ timeout: float = DEFAULT_TIMEOUT
65
+
66
+ @property
67
+ def path(self) -> str:
68
+ return urlsplit(self.url).path
69
+
70
+ @property
71
+ def query(self) -> dict[str, str]:
72
+ return dict(parse_qsl(urlsplit(self.url).query))
73
+
74
+
75
+ @dataclass(slots=True)
76
+ class RawResponse:
77
+ status: int
78
+ reason: str = ""
79
+ headers: Headers = field(default_factory=Headers)
80
+ body: bytes = b""
81
+
82
+ def json(self) -> Any:
83
+ if not self.body:
84
+ return None
85
+ return json.loads(self.body.decode("utf-8"))
86
+
87
+
88
+ class SyncTransport(Protocol):
89
+ def send(self, request: RawRequest) -> RawResponse: ...
90
+
91
+ def close(self) -> None: ...
92
+
93
+
94
+ class AsyncTransport(Protocol):
95
+ async def send(self, request: RawRequest) -> RawResponse: ...
96
+
97
+ async def close(self) -> None: ...
yapperpy/_http/sync.py ADDED
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import http.client
4
+ import ssl
5
+ import threading
6
+ from urllib.parse import urlsplit
7
+
8
+ from .base import Headers, RawRequest, RawResponse, TransportError, TransportTimeout
9
+
10
+
11
+ class HTTPSyncTransport:
12
+ def __init__(
13
+ self,
14
+ *,
15
+ timeout: float = 40.0,
16
+ ssl_context: ssl.SSLContext | None = None,
17
+ max_idle: int = 4,
18
+ ) -> None:
19
+ self.timeout = timeout
20
+ self.ssl_context = ssl_context or ssl.create_default_context()
21
+ self.max_idle = max(1, max_idle)
22
+ self._pool: dict[tuple[str, int], list[http.client.HTTPConnection]] = {}
23
+ self._lock = threading.Lock()
24
+
25
+ def _checkout(self, host: str, port: int, secure: bool) -> http.client.HTTPConnection:
26
+ with self._lock:
27
+ idle = self._pool.get((host, port))
28
+ if idle:
29
+ return idle.pop()
30
+ if secure:
31
+ return http.client.HTTPSConnection(
32
+ host, port, timeout=self.timeout, context=self.ssl_context
33
+ )
34
+ return http.client.HTTPConnection(host, port, timeout=self.timeout)
35
+
36
+ def _checkin(self, host: str, port: int, connection: http.client.HTTPConnection) -> None:
37
+ with self._lock:
38
+ idle = self._pool.setdefault((host, port), [])
39
+ if len(idle) < self.max_idle:
40
+ idle.append(connection)
41
+ return
42
+ connection.close()
43
+
44
+ def send(self, request: RawRequest) -> RawResponse:
45
+ parts = urlsplit(request.url)
46
+ host = parts.hostname or ""
47
+ secure = parts.scheme != "http"
48
+ port = parts.port or (443 if secure else 80)
49
+ path = parts.path or "/"
50
+ if parts.query:
51
+ path = f"{path}?{parts.query}"
52
+ headers = dict(request.headers)
53
+ headers.setdefault("Accept", "application/json")
54
+ headers.setdefault("Connection", "keep-alive")
55
+ connection = self._checkout(host, port, secure)
56
+ try:
57
+ connection.request(request.method, path, body=request.body, headers=headers)
58
+ response = connection.getresponse()
59
+ body = response.read()
60
+ except TimeoutError as exc:
61
+ connection.close()
62
+ raise TransportTimeout(str(exc) or "request timed out") from exc
63
+ except (http.client.HTTPException, OSError) as exc:
64
+ connection.close()
65
+ raise TransportError(str(exc) or type(exc).__name__) from exc
66
+ result = RawResponse(
67
+ status=response.status,
68
+ reason=response.reason,
69
+ headers=Headers(response.getheaders()),
70
+ body=body,
71
+ )
72
+ if response.will_close:
73
+ connection.close()
74
+ else:
75
+ self._checkin(host, port, connection)
76
+ return result
77
+
78
+ def close(self) -> None:
79
+ with self._lock:
80
+ for idle in self._pool.values():
81
+ for connection in idle:
82
+ connection.close()
83
+ self._pool.clear()
yapperpy/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
yapperpy/aio_bot.py ADDED
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import traceback
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from .aio_client import AsyncYapper
10
+ from .context import MessageContext
11
+ from .event_models import InteractionPayload, MessagePayload
12
+ from .framework import BotBase
13
+ from .models import Command, Event
14
+ from .registry import Command as CommandSpec
15
+
16
+ WAIT_SECONDS = 25
17
+ MAX_BACKOFF = 30.0
18
+
19
+
20
+ class AsyncBot(BotBase):
21
+ def __init__(
22
+ self,
23
+ token: str | None = None,
24
+ *,
25
+ client: AsyncYapper | None = None,
26
+ prefixes: tuple[str, ...] = ("/", "!"),
27
+ ignore_bots: bool = True,
28
+ log: bool = True,
29
+ command_limit: int = 100,
30
+ **client_kwargs: Any,
31
+ ) -> None:
32
+ resolved = client if client is not None else AsyncYapper(token, **client_kwargs)
33
+ super().__init__(
34
+ resolved,
35
+ prefixes=prefixes,
36
+ ignore_bots=ignore_bots,
37
+ log=log,
38
+ command_limit=command_limit,
39
+ )
40
+
41
+ def _log(self, message: str) -> None:
42
+ if self.log:
43
+ print(f"[yapperpy] {message}")
44
+
45
+ async def sync_commands(self) -> list[Command]:
46
+ payload = self.command_payload()
47
+ await self.client.bot.set_commands(payload)
48
+ self.registry.dirty = False
49
+ return [Command(name=item["name"], description=item["description"]) for item in payload]
50
+
51
+ async def invoke(self, handler: Callable[..., Any], *args: Any) -> Any:
52
+ result = handler(*args)
53
+ if inspect.isawaitable(result):
54
+ return await result
55
+ return result
56
+
57
+ async def emit(self, event: str, payload: Any) -> None:
58
+ for handler in self.listeners(event):
59
+ try:
60
+ await self.invoke(handler, payload)
61
+ except Exception as exc:
62
+ await self.handle_error(exc, None, event)
63
+
64
+ async def handle_error(
65
+ self, exc: Exception, owner: str | None = None, where: str | None = None
66
+ ) -> None:
67
+ if owner is not None and getattr(exc, "cog", None) is None:
68
+ setattr(exc, "cog", owner)
69
+ setattr(exc, "where", where)
70
+ handlers = self.listeners("error")
71
+ if handlers:
72
+ for handler in handlers:
73
+ try:
74
+ await self.invoke(handler, exc)
75
+ except Exception as inner:
76
+ self._log(f"error listener failed: {inner}")
77
+ return
78
+ if self.log:
79
+ tag = f"[{owner}]" if owner else "[yapperpy]"
80
+ if where:
81
+ tag = f"{tag} {where}"
82
+ print(f"{tag} {type(exc).__name__}: {exc}")
83
+ traceback.print_exception(type(exc), exc, exc.__traceback__, limit=-3)
84
+
85
+ async def handle(self, event: Event) -> None:
86
+ kind, payload = self.prepare(event)
87
+ if kind == "message" and isinstance(payload, MessagePayload):
88
+ await self.handle_message(payload)
89
+ elif kind == "interaction" and isinstance(payload, InteractionPayload):
90
+ await self.handle_interaction(payload)
91
+ else:
92
+ await self.emit(kind, self.build_context(payload))
93
+
94
+ async def handle_message(self, payload: MessagePayload) -> None:
95
+ if self.ignore_bots and payload.author_bot:
96
+ return
97
+ context = self.build_context(payload)
98
+ match = self.resolve_command(payload)
99
+ if match is not None:
100
+ command, args = match
101
+ context.command = command.name
102
+ context.args = args
103
+ if await self.run_check(command, context):
104
+ try:
105
+ await self.invoke(command.handler, context)
106
+ except Exception as exc:
107
+ await self.handle_error(exc, command.owner, f"/{command.name}")
108
+ await self.emit("command", context)
109
+ await self.emit("message", context)
110
+
111
+ async def run_check(self, command: CommandSpec, context: MessageContext) -> bool:
112
+ if command.check is None:
113
+ return True
114
+ try:
115
+ result = command.check(context)
116
+ if inspect.isawaitable(result):
117
+ result = await result
118
+ return result is not False
119
+ except Exception as exc:
120
+ await self.handle_error(exc, command.owner, "check")
121
+ return False
122
+
123
+ async def handle_interaction(self, payload: InteractionPayload) -> None:
124
+ context = self.build_context(payload)
125
+ for entry in self.interaction_handlers(payload.custom_id):
126
+ try:
127
+ await self.invoke(entry.handler, context)
128
+ except Exception as exc:
129
+ await self.handle_error(exc, entry.owner, f"button {entry.matcher}")
130
+ await self.emit("interaction", context)
131
+
132
+ async def run(self) -> None:
133
+ try:
134
+ await self._run()
135
+ except KeyboardInterrupt:
136
+ self._log("stopped")
137
+ finally:
138
+ self.running = False
139
+
140
+ start = run
141
+
142
+ async def _run(self) -> None:
143
+ self.running = True
144
+ self.me = await self.client.bot.get()
145
+ await self.sync_commands()
146
+ await self.emit("ready", self.me)
147
+ if self.me is not None:
148
+ self._log(f"online as {self.me.username}")
149
+ failures = 0
150
+ while self.running:
151
+ try:
152
+ page = await self.client.events.fetch(after=self.last_event_id, wait=WAIT_SECONDS)
153
+ except Exception as exc:
154
+ failures += 1
155
+ await self.handle_error(exc, None, "events")
156
+ await asyncio.sleep(min(MAX_BACKOFF, 2.0**failures))
157
+ continue
158
+ failures = 0
159
+ for event in page.events:
160
+ if event.id <= self.last_event_id:
161
+ continue
162
+ self.last_event_id = event.id
163
+ await self.handle(event)
164
+ if self.registry.dirty:
165
+ await self.sync_commands()
166
+ if page.last > self.last_event_id:
167
+ self.last_event_id = page.last
168
+
169
+ def stop(self) -> None:
170
+ self.running = False