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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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