outnoise 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- outnoise-0.3.0/PKG-INFO +13 -0
- outnoise-0.3.0/pyproject.toml +30 -0
- outnoise-0.3.0/setup.cfg +4 -0
- outnoise-0.3.0/src/outnoise/__init__.py +17 -0
- outnoise-0.3.0/src/outnoise/_base.py +126 -0
- outnoise-0.3.0/src/outnoise/_version.py +1 -0
- outnoise-0.3.0/src/outnoise/async_client.py +116 -0
- outnoise-0.3.0/src/outnoise/client.py +118 -0
- outnoise-0.3.0/src/outnoise/commands.py +118 -0
- outnoise-0.3.0/src/outnoise/components.py +104 -0
- outnoise-0.3.0/src/outnoise/exceptions.py +100 -0
- outnoise-0.3.0/src/outnoise/models.py +36 -0
- outnoise-0.3.0/src/outnoise/py.typed +0 -0
- outnoise-0.3.0/src/outnoise/resources.py +255 -0
- outnoise-0.3.0/src/outnoise.egg-info/PKG-INFO +13 -0
- outnoise-0.3.0/src/outnoise.egg-info/SOURCES.txt +27 -0
- outnoise-0.3.0/src/outnoise.egg-info/dependency_links.txt +1 -0
- outnoise-0.3.0/src/outnoise.egg-info/requires.txt +4 -0
- outnoise-0.3.0/src/outnoise.egg-info/top_level.txt +1 -0
- outnoise-0.3.0/tests/test_async.py +87 -0
- outnoise-0.3.0/tests/test_authentication.py +68 -0
- outnoise-0.3.0/tests/test_commands.py +115 -0
- outnoise-0.3.0/tests/test_errors.py +28 -0
- outnoise-0.3.0/tests/test_group_dms.py +134 -0
- outnoise-0.3.0/tests/test_live_backend.py +18 -0
- outnoise-0.3.0/tests/test_pagination.py +39 -0
- outnoise-0.3.0/tests/test_rate_limits.py +55 -0
- outnoise-0.3.0/tests/test_requests.py +113 -0
- outnoise-0.3.0/tests/test_resources.py +43 -0
outnoise-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: outnoise
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Outnoise - the official Python library for the Outnoise Developer Platform
|
|
5
|
+
Keywords: outnoise,sdk,api,social
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: Typing :: Typed
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: httpx<1.0,>=0.24
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "outnoise"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Outnoise - the official Python library for the Outnoise Developer Platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
dependencies = ["httpx>=0.24,<1.0"]
|
|
12
|
+
keywords = ["outnoise", "sdk", "api", "social"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest>=7"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.package-data]
|
|
26
|
+
outnoise = ["py.typed"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
30
|
+
pythonpath = ["src", "tests"]
|
outnoise-0.3.0/setup.cfg
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Official Python SDK for the Outnoise Developer Platform."""
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
from .async_client import AsyncOutnoise
|
|
4
|
+
from .client import Outnoise
|
|
5
|
+
from .exceptions import (APIConnectionError, APIStatusError, APITimeoutError, AuthenticationError,
|
|
6
|
+
ConfigurationError, InvalidRequestError, MissingScopeError, NotFoundError,
|
|
7
|
+
OutnoiseError, PermissionDeniedError, RateLimitError, ServerError)
|
|
8
|
+
from .commands import Command, Option
|
|
9
|
+
from .components import ActionRow, Button
|
|
10
|
+
from .models import AttrDict, Page
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Outnoise", "AsyncOutnoise", "Page", "AttrDict", "Button", "ActionRow", "Command", "Option", "__version__",
|
|
14
|
+
"OutnoiseError", "ConfigurationError", "APIConnectionError", "APITimeoutError", "APIStatusError",
|
|
15
|
+
"InvalidRequestError", "AuthenticationError", "PermissionDeniedError", "MissingScopeError",
|
|
16
|
+
"NotFoundError", "RateLimitError", "ServerError",
|
|
17
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Configuration, request building and response handling shared by the sync and async clients."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import random
|
|
5
|
+
import time # noqa: F401 (patched in tests)
|
|
6
|
+
from typing import Any, Dict, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .exceptions import APIStatusError, ConfigurationError, OutnoiseError, error_for_status
|
|
12
|
+
from .models import Page, wrap
|
|
13
|
+
|
|
14
|
+
# The beta deployment. Override with base_url=... or OUTNOISE_BASE_URL once you use another host.
|
|
15
|
+
DEFAULT_BASE_URL = "https://beta-chatx.hostmybotprojects.net/api/v1"
|
|
16
|
+
DEFAULT_TIMEOUT = 30.0
|
|
17
|
+
DEFAULT_MAX_RETRIES = 2
|
|
18
|
+
MAX_RETRY_WAIT = 30.0 # never sleep longer than this for a single retry
|
|
19
|
+
_RETRY_STATUS = (502, 503, 504) # transient server errors; retried for GET only
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClientConfig:
|
|
23
|
+
def __init__(self, api_key, access_token, base_url, timeout, max_retries, max_retry_wait):
|
|
24
|
+
api_key = api_key or os.environ.get("OUTNOISE_API_KEY") or None
|
|
25
|
+
access_token = access_token or os.environ.get("OUTNOISE_ACCESS_TOKEN") or None
|
|
26
|
+
if api_key and access_token:
|
|
27
|
+
raise ConfigurationError("Pass either api_key (bot token) or access_token (OAuth2), not both.")
|
|
28
|
+
if not api_key and not access_token:
|
|
29
|
+
raise ConfigurationError(
|
|
30
|
+
"No credentials. Pass api_key=... (bot token) or access_token=... (OAuth2), "
|
|
31
|
+
"or set OUTNOISE_API_KEY / OUTNOISE_ACCESS_TOKEN.")
|
|
32
|
+
base_url = (base_url or os.environ.get("OUTNOISE_BASE_URL") or DEFAULT_BASE_URL).strip().rstrip("/")
|
|
33
|
+
if not base_url.startswith(("http://", "https://")):
|
|
34
|
+
raise ConfigurationError(f"base_url must start with http:// or https:// (got {base_url!r}).")
|
|
35
|
+
if timeout is not None and timeout <= 0:
|
|
36
|
+
raise ConfigurationError("timeout must be positive.")
|
|
37
|
+
if max_retries < 0:
|
|
38
|
+
raise ConfigurationError("max_retries must be 0 or greater.")
|
|
39
|
+
self.base_url = base_url
|
|
40
|
+
self.timeout = DEFAULT_TIMEOUT if timeout is None else timeout
|
|
41
|
+
self.max_retries = max_retries
|
|
42
|
+
self.max_retry_wait = max_retry_wait
|
|
43
|
+
self.headers = {
|
|
44
|
+
"Authorization": ("Bot " + api_key) if api_key else ("Bearer " + access_token),
|
|
45
|
+
"Accept": "application/json",
|
|
46
|
+
"User-Agent": f"outnoise-python/{__version__}",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def __repr__(self): # never print credentials
|
|
50
|
+
return f"<ClientConfig base_url={self.base_url!r}>"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def clean_params(params: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
|
54
|
+
out = {}
|
|
55
|
+
for k, v in (params or {}).items():
|
|
56
|
+
if v is None:
|
|
57
|
+
continue
|
|
58
|
+
out[k] = ("1" if v else "0") if isinstance(v, bool) else str(v)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def retry_delay(attempt: int, retry_after: Optional[float], max_wait: float) -> Optional[float]:
|
|
63
|
+
"""Seconds to wait before retry number `attempt` (0-based), or None if we should give up."""
|
|
64
|
+
if retry_after is not None:
|
|
65
|
+
return None if retry_after > max_wait else max(0.0, retry_after)
|
|
66
|
+
return min(max_wait, 0.5 * (2 ** attempt)) * (0.75 + random.random() / 2)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_retry_after(headers) -> Optional[float]:
|
|
70
|
+
raw = headers.get("Retry-After") if headers is not None else None
|
|
71
|
+
try:
|
|
72
|
+
return float(raw) if raw is not None else None
|
|
73
|
+
except (TypeError, ValueError):
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_error(resp: "httpx.Response") -> APIStatusError:
|
|
78
|
+
body: Any = None
|
|
79
|
+
try:
|
|
80
|
+
body = resp.json()
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
message, code = None, None
|
|
84
|
+
if isinstance(body, dict):
|
|
85
|
+
err = body.get("error")
|
|
86
|
+
if isinstance(err, dict): # tolerate {"error": {"message": ...}}
|
|
87
|
+
message, code = err.get("message"), err.get("code")
|
|
88
|
+
else:
|
|
89
|
+
message = err if isinstance(err, str) else body.get("message")
|
|
90
|
+
code = body.get("code")
|
|
91
|
+
if not code and isinstance(err, str) and body.get("error_description"): # OAuth2 style
|
|
92
|
+
code, message = err, body["error_description"]
|
|
93
|
+
if not message:
|
|
94
|
+
snippet = (resp.text or "").strip().replace("\n", " ")[:200]
|
|
95
|
+
message = snippet or f"HTTP {resp.status_code}"
|
|
96
|
+
return error_for_status(resp.status_code, message, code, body, resp.headers, parse_retry_after(resp.headers))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_success(resp: "httpx.Response") -> Any:
|
|
100
|
+
try:
|
|
101
|
+
body = resp.json()
|
|
102
|
+
except ValueError:
|
|
103
|
+
raise OutnoiseError(f"The API returned a non-JSON response (HTTP {resp.status_code}).") from None
|
|
104
|
+
if not isinstance(body, dict) or "data" not in body:
|
|
105
|
+
raise OutnoiseError("Unexpected API response shape (missing 'data').")
|
|
106
|
+
data = wrap(body["data"])
|
|
107
|
+
if isinstance(body.get("paging"), dict) and isinstance(data, list):
|
|
108
|
+
return Page(data, body["paging"])
|
|
109
|
+
return data
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def request_kwargs(method: str, path: str, params, json_body) -> Tuple[str, Dict[str, Any]]:
|
|
113
|
+
kw: Dict[str, Any] = {}
|
|
114
|
+
q = clean_params(params)
|
|
115
|
+
if q:
|
|
116
|
+
kw["params"] = q
|
|
117
|
+
if json_body is not None:
|
|
118
|
+
kw["json"] = json_body
|
|
119
|
+
return method.upper(), kw
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_page_args(limit: int, offset: int):
|
|
123
|
+
if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 50:
|
|
124
|
+
raise ValueError("limit must be an integer between 1 and 50.")
|
|
125
|
+
if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
|
|
126
|
+
raise ValueError("offset must be an integer of 0 or greater.")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Asynchronous client (same API as :class:`outnoise.Outnoise`, use with ``await`` / ``async for``)."""
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import Any, AsyncIterator, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from . import _base, resources
|
|
8
|
+
from .exceptions import APIConnectionError, APITimeoutError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AsyncOutnoise:
|
|
12
|
+
"""Asynchronous Outnoise API client.
|
|
13
|
+
|
|
14
|
+
>>> async with AsyncOutnoise(api_key="YOUR_BOT_TOKEN") as client:
|
|
15
|
+
... me = await client.users.me()
|
|
16
|
+
... async for post in client.posts.iter(max_items=100):
|
|
17
|
+
... print(post.content)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, api_key: Optional[str] = None, *, access_token: Optional[str] = None,
|
|
21
|
+
base_url: Optional[str] = None, timeout: Optional[float] = None,
|
|
22
|
+
max_retries: int = _base.DEFAULT_MAX_RETRIES, max_retry_wait: float = _base.MAX_RETRY_WAIT,
|
|
23
|
+
http_client: Optional[httpx.AsyncClient] = None):
|
|
24
|
+
self._cfg = _base.ClientConfig(api_key, access_token, base_url, timeout, max_retries, max_retry_wait)
|
|
25
|
+
self._owns_http = http_client is None
|
|
26
|
+
self._http = http_client or httpx.AsyncClient(timeout=self._cfg.timeout)
|
|
27
|
+
self._explicit_timeout = timeout if (timeout is not None or http_client is None) else None
|
|
28
|
+
resources.attach(self)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def base_url(self) -> str:
|
|
32
|
+
return self._cfg.base_url
|
|
33
|
+
|
|
34
|
+
def __repr__(self):
|
|
35
|
+
return f"<AsyncOutnoise base_url={self._cfg.base_url!r}>"
|
|
36
|
+
|
|
37
|
+
async def __aenter__(self):
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
async def __aexit__(self, *exc):
|
|
41
|
+
await self.aclose()
|
|
42
|
+
|
|
43
|
+
async def aclose(self) -> None:
|
|
44
|
+
if self._owns_http:
|
|
45
|
+
await self._http.aclose()
|
|
46
|
+
|
|
47
|
+
async def _call(self, method: str, path: str, params: Optional[Dict[str, Any]] = None,
|
|
48
|
+
json_body: Optional[Any] = None) -> Any:
|
|
49
|
+
method, kw = _base.request_kwargs(method, path, params, json_body)
|
|
50
|
+
if self._explicit_timeout is not None:
|
|
51
|
+
kw["timeout"] = self._explicit_timeout
|
|
52
|
+
url = self._cfg.base_url + path
|
|
53
|
+
idempotent = method in ("GET", "HEAD")
|
|
54
|
+
attempt = 0
|
|
55
|
+
while True:
|
|
56
|
+
try:
|
|
57
|
+
resp = await self._http.request(method, url, headers=self._cfg.headers, **kw)
|
|
58
|
+
except httpx.TimeoutException as exc:
|
|
59
|
+
if idempotent and attempt < self._cfg.max_retries:
|
|
60
|
+
await self._wait(attempt)
|
|
61
|
+
attempt += 1
|
|
62
|
+
continue
|
|
63
|
+
raise APITimeoutError(f"Request to {path} timed out.") from exc
|
|
64
|
+
except httpx.TransportError as exc:
|
|
65
|
+
if idempotent and attempt < self._cfg.max_retries:
|
|
66
|
+
await self._wait(attempt)
|
|
67
|
+
attempt += 1
|
|
68
|
+
continue
|
|
69
|
+
raise APIConnectionError(f"Could not connect to {self._cfg.base_url}: {exc}") from exc
|
|
70
|
+
if 200 <= resp.status_code < 300:
|
|
71
|
+
return _base.parse_success(resp)
|
|
72
|
+
retryable = resp.status_code == 429 or (idempotent and resp.status_code in _base._RETRY_STATUS)
|
|
73
|
+
if retryable and attempt < self._cfg.max_retries:
|
|
74
|
+
delay = _base.retry_delay(attempt, _base.parse_retry_after(resp.headers), self._cfg.max_retry_wait)
|
|
75
|
+
if delay is not None:
|
|
76
|
+
await asyncio.sleep(delay)
|
|
77
|
+
attempt += 1
|
|
78
|
+
continue
|
|
79
|
+
raise _base.build_error(resp)
|
|
80
|
+
|
|
81
|
+
async def _wait(self, attempt: int) -> None:
|
|
82
|
+
delay = _base.retry_delay(attempt, None, self._cfg.max_retry_wait)
|
|
83
|
+
if delay:
|
|
84
|
+
await asyncio.sleep(delay)
|
|
85
|
+
|
|
86
|
+
async def _iterate(self, path: str, params: Dict[str, Any], page_size: int,
|
|
87
|
+
max_items: Optional[int]) -> AsyncIterator[Any]:
|
|
88
|
+
_base.validate_page_args(page_size, 0)
|
|
89
|
+
offset, yielded = 0, 0
|
|
90
|
+
while True:
|
|
91
|
+
page = await self._call("GET", path, params=dict(params, limit=page_size, offset=offset))
|
|
92
|
+
for item in page:
|
|
93
|
+
yield item
|
|
94
|
+
yielded += 1
|
|
95
|
+
if max_items is not None and yielded >= max_items:
|
|
96
|
+
return
|
|
97
|
+
if not page.has_more:
|
|
98
|
+
return
|
|
99
|
+
offset = page.next_offset
|
|
100
|
+
|
|
101
|
+
async def _poll_interactions(self, interval: float, after: int, limit: int, include_responded: bool,
|
|
102
|
+
max_items: Optional[int]) -> AsyncIterator[Any]:
|
|
103
|
+
cursor, yielded = after, 0
|
|
104
|
+
while True:
|
|
105
|
+
page = await self.interactions.list(after=cursor, limit=limit)
|
|
106
|
+
for it in page:
|
|
107
|
+
cursor = max(cursor, it["id"])
|
|
108
|
+
if it.get("responded") and not include_responded:
|
|
109
|
+
continue
|
|
110
|
+
yield it
|
|
111
|
+
yielded += 1
|
|
112
|
+
if max_items is not None and yielded >= max_items:
|
|
113
|
+
return
|
|
114
|
+
cursor = max(cursor, page.paging.get("next_after") or 0)
|
|
115
|
+
if not page.has_more:
|
|
116
|
+
await asyncio.sleep(interval)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Synchronous client."""
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, Dict, Iterator, Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from . import _base, resources
|
|
8
|
+
from .exceptions import APIConnectionError, APITimeoutError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Outnoise:
|
|
12
|
+
"""Synchronous Outnoise API client.
|
|
13
|
+
|
|
14
|
+
>>> client = Outnoise(api_key="YOUR_BOT_TOKEN")
|
|
15
|
+
>>> client.users.me()
|
|
16
|
+
|
|
17
|
+
Pass ``api_key`` (a bot token) **or** ``access_token`` (an OAuth2 token). Both can also come from the
|
|
18
|
+
``OUTNOISE_API_KEY`` / ``OUTNOISE_ACCESS_TOKEN`` environment variables, and the API address from
|
|
19
|
+
``OUTNOISE_BASE_URL``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, api_key: Optional[str] = None, *, access_token: Optional[str] = None,
|
|
23
|
+
base_url: Optional[str] = None, timeout: Optional[float] = None,
|
|
24
|
+
max_retries: int = _base.DEFAULT_MAX_RETRIES, max_retry_wait: float = _base.MAX_RETRY_WAIT,
|
|
25
|
+
http_client: Optional[httpx.Client] = None):
|
|
26
|
+
self._cfg = _base.ClientConfig(api_key, access_token, base_url, timeout, max_retries, max_retry_wait)
|
|
27
|
+
self._owns_http = http_client is None
|
|
28
|
+
self._http = http_client or httpx.Client(timeout=self._cfg.timeout)
|
|
29
|
+
self._explicit_timeout = timeout if (timeout is not None or http_client is None) else None
|
|
30
|
+
resources.attach(self)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def base_url(self) -> str:
|
|
34
|
+
return self._cfg.base_url
|
|
35
|
+
|
|
36
|
+
def __repr__(self):
|
|
37
|
+
return f"<Outnoise base_url={self._cfg.base_url!r}>"
|
|
38
|
+
|
|
39
|
+
def __enter__(self):
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(self, *exc):
|
|
43
|
+
self.close()
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
if self._owns_http:
|
|
47
|
+
self._http.close()
|
|
48
|
+
|
|
49
|
+
# ── request machinery ────────────────────────────────────────────────────
|
|
50
|
+
def _call(self, method: str, path: str, params: Optional[Dict[str, Any]] = None,
|
|
51
|
+
json_body: Optional[Any] = None) -> Any:
|
|
52
|
+
method, kw = _base.request_kwargs(method, path, params, json_body)
|
|
53
|
+
if self._explicit_timeout is not None:
|
|
54
|
+
kw["timeout"] = self._explicit_timeout
|
|
55
|
+
url = self._cfg.base_url + path
|
|
56
|
+
idempotent = method in ("GET", "HEAD")
|
|
57
|
+
attempt = 0
|
|
58
|
+
while True:
|
|
59
|
+
try:
|
|
60
|
+
resp = self._http.request(method, url, headers=self._cfg.headers, **kw)
|
|
61
|
+
except httpx.TimeoutException as exc:
|
|
62
|
+
if idempotent and attempt < self._cfg.max_retries:
|
|
63
|
+
self._wait(attempt, None)
|
|
64
|
+
attempt += 1
|
|
65
|
+
continue
|
|
66
|
+
raise APITimeoutError(f"Request to {path} timed out.") from exc
|
|
67
|
+
except httpx.TransportError as exc:
|
|
68
|
+
if idempotent and attempt < self._cfg.max_retries:
|
|
69
|
+
self._wait(attempt, None)
|
|
70
|
+
attempt += 1
|
|
71
|
+
continue
|
|
72
|
+
raise APIConnectionError(f"Could not connect to {self._cfg.base_url}: {exc}") from exc
|
|
73
|
+
if 200 <= resp.status_code < 300:
|
|
74
|
+
return _base.parse_success(resp)
|
|
75
|
+
retryable = resp.status_code == 429 or (idempotent and resp.status_code in _base._RETRY_STATUS)
|
|
76
|
+
if retryable and attempt < self._cfg.max_retries:
|
|
77
|
+
delay = _base.retry_delay(attempt, _base.parse_retry_after(resp.headers), self._cfg.max_retry_wait)
|
|
78
|
+
if delay is not None:
|
|
79
|
+
time.sleep(delay)
|
|
80
|
+
attempt += 1
|
|
81
|
+
continue
|
|
82
|
+
raise _base.build_error(resp)
|
|
83
|
+
|
|
84
|
+
def _wait(self, attempt: int, retry_after: Optional[float]) -> None:
|
|
85
|
+
delay = _base.retry_delay(attempt, retry_after, self._cfg.max_retry_wait)
|
|
86
|
+
if delay:
|
|
87
|
+
time.sleep(delay)
|
|
88
|
+
|
|
89
|
+
def _iterate(self, path: str, params: Dict[str, Any], page_size: int, max_items: Optional[int]) -> Iterator[Any]:
|
|
90
|
+
_base.validate_page_args(page_size, 0)
|
|
91
|
+
offset, yielded = 0, 0
|
|
92
|
+
while True:
|
|
93
|
+
page = self._call("GET", path, params=dict(params, limit=page_size, offset=offset))
|
|
94
|
+
for item in page:
|
|
95
|
+
yield item
|
|
96
|
+
yielded += 1
|
|
97
|
+
if max_items is not None and yielded >= max_items:
|
|
98
|
+
return
|
|
99
|
+
if not page.has_more:
|
|
100
|
+
return
|
|
101
|
+
offset = page.next_offset
|
|
102
|
+
|
|
103
|
+
def _poll_interactions(self, interval: float, after: int, limit: int, include_responded: bool,
|
|
104
|
+
max_items: Optional[int]) -> Iterator[Any]:
|
|
105
|
+
cursor, yielded = after, 0
|
|
106
|
+
while True:
|
|
107
|
+
page = self.interactions.list(after=cursor, limit=limit)
|
|
108
|
+
for it in page:
|
|
109
|
+
cursor = max(cursor, it["id"])
|
|
110
|
+
if it.get("responded") and not include_responded:
|
|
111
|
+
continue
|
|
112
|
+
yield it
|
|
113
|
+
yielded += 1
|
|
114
|
+
if max_items is not None and yielded >= max_items:
|
|
115
|
+
return
|
|
116
|
+
cursor = max(cursor, page.paging.get("next_after") or 0)
|
|
117
|
+
if not page.has_more:
|
|
118
|
+
time.sleep(interval)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Slash commands for your bot.
|
|
2
|
+
|
|
3
|
+
from outnoise import Command, Option
|
|
4
|
+
|
|
5
|
+
client.commands.sync([
|
|
6
|
+
Command("ping", "Check that the bot is alive"),
|
|
7
|
+
Command("poll", "Start a poll", options=[
|
|
8
|
+
Option("question", "What to ask", required=True),
|
|
9
|
+
Option("days", "How long it runs", type="integer", choices=[("1 day", 1), ("3 days", 3)]),
|
|
10
|
+
Option("anonymous", "Hide who voted", type="boolean"),
|
|
11
|
+
]),
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
Members run them by typing ``/`` in a group DM your bot belongs to; you receive them as interactions
|
|
15
|
+
(``click.type == "slash_command"``). Slash commands exist only in group DMs - not in posts, comments,
|
|
16
|
+
one-to-one chats or auto posts.
|
|
17
|
+
"""
|
|
18
|
+
import re
|
|
19
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
|
20
|
+
|
|
21
|
+
OPTION_TYPES = ("string", "integer", "boolean")
|
|
22
|
+
MAX_COMMANDS = 25
|
|
23
|
+
MAX_OPTIONS = 10
|
|
24
|
+
MAX_CHOICES = 25
|
|
25
|
+
_NAME = re.compile(r"^[a-z0-9_-]{1,32}$")
|
|
26
|
+
|
|
27
|
+
Choice = Union[Tuple[str, Any], Dict[str, Any]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Option:
|
|
31
|
+
"""One argument of a command. ``choices`` may be ``[(label, value), ...]`` or ``[{"name":..., "value":...}]``."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, name: str, description: str, *, type: str = "string", required: bool = False,
|
|
34
|
+
choices: Optional[Sequence[Choice]] = None):
|
|
35
|
+
self.name = name
|
|
36
|
+
self.description = description
|
|
37
|
+
self.type = type
|
|
38
|
+
self.required = required
|
|
39
|
+
self.choices = choices
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
42
|
+
d: Dict[str, Any] = {"name": self.name, "description": self.description, "type": self.type,
|
|
43
|
+
"required": bool(self.required)}
|
|
44
|
+
if self.choices:
|
|
45
|
+
d["choices"] = [c if isinstance(c, dict) else {"name": c[0], "value": c[1]} for c in self.choices]
|
|
46
|
+
return d
|
|
47
|
+
|
|
48
|
+
def __repr__(self):
|
|
49
|
+
return f"<Option {self.name!r} {self.type}{' required' if self.required else ''}>"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Command:
|
|
53
|
+
"""A slash command: ``/name`` plus an optional list of :class:`Option`."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, name: str, description: str, options: Optional[Iterable[Any]] = None):
|
|
56
|
+
self.name = name
|
|
57
|
+
self.description = description
|
|
58
|
+
self.options = list(options or [])
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
61
|
+
return {"name": self.name, "description": self.description,
|
|
62
|
+
"options": [o.to_dict() if isinstance(o, Option) else dict(o) for o in self.options]}
|
|
63
|
+
|
|
64
|
+
def __repr__(self):
|
|
65
|
+
return f"<Command /{self.name}>"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_name(value: Any, what: str) -> str:
|
|
69
|
+
name = str(value or "").strip().lower().lstrip("/") if what == "command" else str(value or "").strip().lower()
|
|
70
|
+
if not _NAME.match(name):
|
|
71
|
+
raise ValueError(f"A {what} name must be 1-32 characters: lowercase letters, numbers, _ and -.")
|
|
72
|
+
return name
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def normalize_one(command: Any) -> Dict[str, Any]:
|
|
76
|
+
d = command.to_dict() if isinstance(command, Command) else dict(command) if isinstance(command, dict) else None
|
|
77
|
+
if d is None:
|
|
78
|
+
raise ValueError("A command must be a Command or a dict.")
|
|
79
|
+
name = _check_name(d.get("name"), "command")
|
|
80
|
+
desc = " ".join(str(d.get("description") or "").split())
|
|
81
|
+
if not desc or len(desc) > 100:
|
|
82
|
+
raise ValueError(f"/{name}: the description must be 1-100 characters.")
|
|
83
|
+
raw_opts = d.get("options") or []
|
|
84
|
+
if len(raw_opts) > MAX_OPTIONS:
|
|
85
|
+
raise ValueError(f"/{name}: at most {MAX_OPTIONS} options are allowed.")
|
|
86
|
+
options, seen = [], set()
|
|
87
|
+
for o in raw_opts:
|
|
88
|
+
od = o.to_dict() if isinstance(o, Option) else dict(o)
|
|
89
|
+
oname = _check_name(od.get("name"), "option")
|
|
90
|
+
if oname in seen:
|
|
91
|
+
raise ValueError(f"/{name}: option {oname!r} is listed twice.")
|
|
92
|
+
seen.add(oname)
|
|
93
|
+
odesc = " ".join(str(od.get("description") or "").split())
|
|
94
|
+
if not odesc or len(odesc) > 100:
|
|
95
|
+
raise ValueError(f"/{name} {oname}: the description must be 1-100 characters.")
|
|
96
|
+
otype = od.get("type") or "string"
|
|
97
|
+
if otype not in OPTION_TYPES:
|
|
98
|
+
raise ValueError(f"/{name} {oname}: type must be one of: " + ", ".join(OPTION_TYPES))
|
|
99
|
+
item: Dict[str, Any] = {"name": oname, "description": odesc, "type": otype, "required": bool(od.get("required"))}
|
|
100
|
+
if od.get("choices"):
|
|
101
|
+
if otype == "boolean" or len(od["choices"]) > MAX_CHOICES:
|
|
102
|
+
raise ValueError(f"/{name} {oname}: choices are not allowed for boolean options, and at most {MAX_CHOICES} are allowed.")
|
|
103
|
+
item["choices"] = od["choices"]
|
|
104
|
+
options.append(item)
|
|
105
|
+
return {"name": name, "description": desc, "options": options}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def normalize(commands: Iterable[Any]) -> List[Dict[str, Any]]:
|
|
109
|
+
out, seen = [], set()
|
|
110
|
+
for c in commands:
|
|
111
|
+
d = normalize_one(c)
|
|
112
|
+
if d["name"] in seen:
|
|
113
|
+
raise ValueError(f"The command /{d['name']} is listed twice.")
|
|
114
|
+
seen.add(d["name"])
|
|
115
|
+
out.append(d)
|
|
116
|
+
if len(out) > MAX_COMMANDS:
|
|
117
|
+
raise ValueError(f"At most {MAX_COMMANDS} commands are allowed.")
|
|
118
|
+
return out
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Message buttons for group DMs.
|
|
2
|
+
|
|
3
|
+
from outnoise import Button
|
|
4
|
+
|
|
5
|
+
client.group_dms.send(group_id, "Movie tonight?", components=[
|
|
6
|
+
Button("Yes", custom_id="vote_yes", style="success"),
|
|
7
|
+
Button("No", custom_id="vote_no", style="danger"),
|
|
8
|
+
Button("Docs", style="link", url="https://example.com/docs"),
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
A flat list of buttons is one row; pass a list of lists for several rows (max 5 rows x 5 buttons).
|
|
12
|
+
Plain dicts work too: ``{"label": "Yes", "custom_id": "vote_yes", "style": "success"}``.
|
|
13
|
+
"""
|
|
14
|
+
import re
|
|
15
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
16
|
+
|
|
17
|
+
STYLES = ("primary", "secondary", "success", "danger", "link")
|
|
18
|
+
MAX_ROWS = 5
|
|
19
|
+
MAX_PER_ROW = 5
|
|
20
|
+
_CUSTOM_ID = re.compile(r"^[A-Za-z0-9_:.\-]{1,64}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Button:
|
|
24
|
+
"""A clickable button. Link buttons open a URL; every other style sends an interaction to your bot."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, label: str, custom_id: Optional[str] = None, *, style: Optional[str] = None,
|
|
27
|
+
url: Optional[str] = None, disabled: bool = False):
|
|
28
|
+
self.label = label
|
|
29
|
+
self.custom_id = custom_id
|
|
30
|
+
self.style = style or ("link" if url else "primary")
|
|
31
|
+
self.url = url
|
|
32
|
+
self.disabled = disabled
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
35
|
+
d: Dict[str, Any] = {"label": self.label, "style": self.style}
|
|
36
|
+
if self.style == "link":
|
|
37
|
+
d["url"] = self.url
|
|
38
|
+
else:
|
|
39
|
+
d["custom_id"] = self.custom_id
|
|
40
|
+
if self.disabled:
|
|
41
|
+
d["disabled"] = True
|
|
42
|
+
return d
|
|
43
|
+
|
|
44
|
+
def __repr__(self):
|
|
45
|
+
return f"<Button {self.label!r} style={self.style!r}>"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ActionRow(list):
|
|
49
|
+
"""A row of buttons (just a list, provided for readability)."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, *buttons):
|
|
52
|
+
super().__init__(buttons)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _button_dict(b: Any) -> Dict[str, Any]:
|
|
56
|
+
d = b.to_dict() if isinstance(b, Button) else dict(b) if isinstance(b, dict) else None
|
|
57
|
+
if d is None:
|
|
58
|
+
raise ValueError("Every button must be a Button or a dict.")
|
|
59
|
+
label = " ".join(str(d.get("label") or "").split())
|
|
60
|
+
if not label or len(label) > 40:
|
|
61
|
+
raise ValueError("A button label must be 1-40 characters.")
|
|
62
|
+
style = d.get("style") or ("link" if d.get("url") else "primary")
|
|
63
|
+
if style not in STYLES:
|
|
64
|
+
raise ValueError("Button style must be one of: " + ", ".join(STYLES))
|
|
65
|
+
out: Dict[str, Any] = {"label": label, "style": style}
|
|
66
|
+
if style == "link":
|
|
67
|
+
url = str(d.get("url") or "")
|
|
68
|
+
if not url.startswith("https://"):
|
|
69
|
+
raise ValueError("A link button needs an https:// url.")
|
|
70
|
+
out["url"] = url
|
|
71
|
+
else:
|
|
72
|
+
cid = str(d.get("custom_id") or "")
|
|
73
|
+
if not _CUSTOM_ID.match(cid):
|
|
74
|
+
raise ValueError("A button needs a custom_id of 1-64 letters, numbers and _ : . -")
|
|
75
|
+
out["custom_id"] = cid
|
|
76
|
+
if d.get("disabled"):
|
|
77
|
+
out["disabled"] = True
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def normalize(components: Optional[Iterable[Any]]) -> Optional[List[List[Dict[str, Any]]]]:
|
|
82
|
+
"""Buttons / dicts / rows -> the list-of-rows the API expects. ``None`` stays ``None``; ``[]`` clears buttons."""
|
|
83
|
+
if components is None:
|
|
84
|
+
return None
|
|
85
|
+
items = list(components)
|
|
86
|
+
if items and all(isinstance(x, (Button, dict)) for x in items):
|
|
87
|
+
items = [items] # flat list = one row
|
|
88
|
+
if len(items) > MAX_ROWS:
|
|
89
|
+
raise ValueError(f"At most {MAX_ROWS} rows of buttons are allowed.")
|
|
90
|
+
rows, seen = [], set()
|
|
91
|
+
for row in items:
|
|
92
|
+
if not isinstance(row, (list, tuple)) or not row:
|
|
93
|
+
raise ValueError("Every row must be a non-empty list of buttons.")
|
|
94
|
+
if len(row) > MAX_PER_ROW:
|
|
95
|
+
raise ValueError(f"At most {MAX_PER_ROW} buttons are allowed per row.")
|
|
96
|
+
out = [_button_dict(b) for b in row]
|
|
97
|
+
for b in out:
|
|
98
|
+
cid = b.get("custom_id")
|
|
99
|
+
if cid:
|
|
100
|
+
if cid in seen:
|
|
101
|
+
raise ValueError(f"custom_id {cid!r} is used by more than one button.")
|
|
102
|
+
seen.add(cid)
|
|
103
|
+
rows.append(out)
|
|
104
|
+
return rows
|