satori-python 0.16.3__tar.gz → 0.16.5__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.
Files changed (29) hide show
  1. {satori_python-0.16.3 → satori_python-0.16.5}/PKG-INFO +15 -1
  2. {satori_python-0.16.3 → satori_python-0.16.5}/README.md +14 -0
  3. {satori_python-0.16.3 → satori_python-0.16.5}/pyproject.toml +2 -1
  4. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/__init__.py +1 -1
  5. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/client/__init__.py +27 -0
  6. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/client/network/base.py +1 -1
  7. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/element.py +5 -2
  8. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/model.py +1 -1
  9. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/parser.py +2 -2
  10. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/server/__init__.py +80 -21
  11. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/server/route.py +6 -6
  12. {satori_python-0.16.3 → satori_python-0.16.5}/LICENSE +0 -0
  13. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/client/account.py +0 -0
  14. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/client/account.pyi +0 -0
  15. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/client/config.py +0 -0
  16. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/client/network/__init__.py +0 -0
  17. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/client/network/util.py +0 -0
  18. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/client/network/webhook.py +0 -0
  19. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/client/network/websocket.py +0 -0
  20. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/client/protocol.py +0 -0
  21. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/const.py +0 -0
  22. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/event.py +0 -0
  23. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/exception.py +0 -0
  24. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/server/adapter.py +0 -0
  25. /satori_python-0.16.3/src/satori/server/conection.py → /satori_python-0.16.5/src/satori/server/connection.py +0 -0
  26. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/server/formdata.py +0 -0
  27. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/server/model.py +0 -0
  28. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/server/utils.py +0 -0
  29. {satori_python-0.16.3 → satori_python-0.16.5}/src/satori/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: satori-python
3
- Version: 0.16.3
3
+ Version: 0.16.5
4
4
  Summary: Satori Protocol SDK for python
5
5
  Home-page: https://github.com/RF-Tar-Railt/satori-python
6
6
  Author-Email: RF-Tar-Railt <rf_tar_railt@qq.com>
@@ -75,6 +75,20 @@ pip install satori-python-client
75
75
  pip install satori-python-server
76
76
  ```
77
77
 
78
+ ### 官方适配器
79
+
80
+ | 适配器 | 安装 | 路径 |
81
+ |------------|----------------------------------------------|--------------------------------------------------------------------|
82
+ | Satori | `pip install satori-python-adapter-satori` | satori.adapters.satori |
83
+ | OneBot V11 | `pip install satori-python-adapter-onebot11` | satori.adapters.onebot11.forward, satori.adapters.onebot11.reverse |
84
+ | Console | `pip install satori-python-adapter-console` | satori.adapters.console |
85
+
86
+ ### 社区适配器
87
+
88
+ | 适配器 | 安装 | 路径 |
89
+ |-------------------|-----------------------|--------------|
90
+ | nekobox(Lagrange) | `pip install nekobox` | nekobox.main |
91
+
78
92
  ## 使用
79
93
 
80
94
  客户端:
@@ -45,6 +45,20 @@ pip install satori-python-client
45
45
  pip install satori-python-server
46
46
  ```
47
47
 
48
+ ### 官方适配器
49
+
50
+ | 适配器 | 安装 | 路径 |
51
+ |------------|----------------------------------------------|--------------------------------------------------------------------|
52
+ | Satori | `pip install satori-python-adapter-satori` | satori.adapters.satori |
53
+ | OneBot V11 | `pip install satori-python-adapter-onebot11` | satori.adapters.onebot11.forward, satori.adapters.onebot11.reverse |
54
+ | Console | `pip install satori-python-adapter-console` | satori.adapters.console |
55
+
56
+ ### 社区适配器
57
+
58
+ | 适配器 | 安装 | 路径 |
59
+ |-------------------|-----------------------|--------------|
60
+ | nekobox(Lagrange) | `pip install nekobox` | nekobox.main |
61
+
48
62
  ## 使用
49
63
 
50
64
  客户端:
@@ -29,7 +29,7 @@ classifiers = [
29
29
  "Programming Language :: Python :: 3.12",
30
30
  "Operating System :: OS Independent",
31
31
  ]
32
- version = "0.16.3"
32
+ version = "0.16.5"
33
33
 
34
34
  [project.license]
35
35
  text = "MIT"
@@ -54,6 +54,7 @@ dev = [
54
54
  "fix-future-annotations>=0.5.0",
55
55
  "mina-build<0.6,>=0.5.1",
56
56
  "pdm-mina>=0.3.2",
57
+ "nonechat<0.7.0,>=0.6.0",
57
58
  ]
58
59
 
59
60
  [tool.pdm.build]
@@ -41,4 +41,4 @@ from .model import Role as Role
41
41
  from .model import Upload as Upload
42
42
  from .model import User as User
43
43
 
44
- __version__ = "0.16.3"
44
+ __version__ = "0.16.5"
@@ -35,6 +35,28 @@ MAPPING: dict[type[Config], type[BaseNetwork]] = {
35
35
  WebsocketsInfo: WsNetwork,
36
36
  }
37
37
 
38
+ _app: App | None = None
39
+
40
+
41
+ def get_accounts() -> dict[str, Account]:
42
+ if _app is None:
43
+ raise RuntimeError("App instance is not initialized.")
44
+ return _app.accounts
45
+
46
+
47
+ def get_app() -> App:
48
+ """Get the current App instance."""
49
+ if _app is None:
50
+ raise RuntimeError("App instance is not initialized.")
51
+ return _app
52
+
53
+
54
+ def get_account(self_id: str) -> Account:
55
+ """Get an account by its self_id."""
56
+ if _app is None:
57
+ raise RuntimeError("App instance is not initialized.")
58
+ return _app.get_account(self_id)
59
+
38
60
 
39
61
  class App(Service):
40
62
  id = "satori-python.client"
@@ -51,6 +73,10 @@ class App(Service):
51
73
  MAPPING[tc] = tn
52
74
 
53
75
  def __init__(self, *configs: Config, default_api_cls: type[ApiProtocol] = ApiProtocol):
76
+ global _app
77
+
78
+ if _app is not None:
79
+ raise RuntimeError("App instance already exists. Only one App instance is allowed.")
54
80
  self.accounts = {}
55
81
  self.connections = []
56
82
  self.event_callbacks = []
@@ -59,6 +85,7 @@ class App(Service):
59
85
  for config in configs:
60
86
  self.apply(config)
61
87
  self.default_api_cls = default_api_cls
88
+ _app = self
62
89
 
63
90
  def apply(self, config: Config):
64
91
  try:
@@ -8,7 +8,7 @@ from launart import Service
8
8
  from ..config import Config as Config
9
9
 
10
10
  if TYPE_CHECKING:
11
- from .. import Account, App
11
+ from satori.client import Account, App
12
12
 
13
13
  TConfig = TypeVar("TConfig", bound=Config)
14
14
 
@@ -180,6 +180,7 @@ class Resource(Element):
180
180
  raw: Optional[Union[bytes, BytesIO]] = None,
181
181
  mime: Optional[str] = None,
182
182
  name: Optional[str] = None,
183
+ duration: Optional[float] = None,
183
184
  poster: Optional[str] = None,
184
185
  extra: Optional[dict[str, Any]] = None,
185
186
  cache: Optional[bool] = None,
@@ -198,6 +199,8 @@ class Resource(Element):
198
199
  raise ValueError(f"{cls} need at least one of url, path and raw")
199
200
  if name is not None:
200
201
  data["title"] = name
202
+ if duration is not None and cls is Audio:
203
+ data["duration"] = duration
201
204
  if poster is not None and cls in (Video, Audio, File):
202
205
  data["poster"] = poster
203
206
  if cache is not None:
@@ -231,7 +234,7 @@ class Image(Resource):
231
234
  class Audio(Resource):
232
235
  """<audio> 元素用于表示语音。"""
233
236
 
234
- duration: Optional[int] = None
237
+ duration: Optional[float] = None
235
238
  poster: Optional[str] = None
236
239
 
237
240
  __names__ = ("src", "title", "duration", "poster")
@@ -243,7 +246,7 @@ class Video(Resource):
243
246
 
244
247
  width: Optional[int] = None
245
248
  height: Optional[int] = None
246
- duration: Optional[int] = None
249
+ duration: Optional[float] = None
247
250
  poster: Optional[str] = None
248
251
 
249
252
  __names__ = ("src", "title", "width", "height", "duration", "poster")
@@ -46,7 +46,7 @@ class ChannelType(IntEnum):
46
46
  @dataclass
47
47
  class Channel(ModelBase):
48
48
  id: str
49
- type: ChannelType
49
+ type: ChannelType = ChannelType.TEXT
50
50
  name: Optional[str] = None
51
51
  parent_id: Optional[str] = None
52
52
 
@@ -358,9 +358,9 @@ def parse(src: str, context: Optional[dict] = None):
358
358
  def parse_content(source: str, _start: bool, _end: bool):
359
359
  source = unescape(source)
360
360
  if _start:
361
- source = re.sub(r"^\s*\n\s*", "", source, re.MULTILINE)
361
+ source = re.sub(r"^\s*\n\s*", "", source, flags=re.MULTILINE)
362
362
  if _end:
363
- source = re.sub(r"\s*\n\s*$", "", source, re.MULTILINE)
363
+ source = re.sub(r"\s*\n\s*$", "", source, flags=re.MULTILINE)
364
364
  push_text(source)
365
365
 
366
366
  tag_pat = tag_pat2 if context is not None else tag_pat1
@@ -8,30 +8,29 @@ import secrets
8
8
  import signal
9
9
  import threading
10
10
  import urllib.parse
11
- from collections.abc import Iterable
11
+ from collections.abc import Awaitable, Iterable
12
12
  from contextlib import suppress
13
13
  from itertools import chain
14
14
  from pathlib import Path
15
15
  from tempfile import TemporaryDirectory
16
16
  from traceback import print_exc
17
- from typing import Any
17
+ from typing import Any, Callable, TypeVar
18
18
 
19
19
  import aiohttp
20
20
  from creart import it
21
- from graia.amnesia.builtins.asgi import UvicornASGIService
21
+ from graia.amnesia.builtins.aiohttp import AiohttpClientService
22
+ from graia.amnesia.builtins.asgi import UvicornASGIService, asgitypes
22
23
  from launart import Launart, Service, any_completed
23
24
  from loguru import logger
24
25
  from starlette.applications import Starlette
25
26
  from starlette.datastructures import FormData as FormData
26
27
  from starlette.requests import Request as StarletteRequest
27
- from starlette.responses import (
28
- FileResponse,
29
- HTMLResponse,
30
- JSONResponse,
31
- PlainTextResponse,
32
- Response,
33
- StreamingResponse,
34
- )
28
+ from starlette.responses import FileResponse as FileResponse
29
+ from starlette.responses import HTMLResponse as HTMLResponse
30
+ from starlette.responses import JSONResponse as JSONResponse
31
+ from starlette.responses import PlainTextResponse as PlainTextResponse
32
+ from starlette.responses import Response as Response
33
+ from starlette.responses import StreamingResponse as StreamingResponse
35
34
  from starlette.routing import Route, WebSocketRoute
36
35
  from starlette.staticfiles import StaticFiles
37
36
  from starlette.websockets import WebSocket, WebSocketDisconnect
@@ -42,7 +41,7 @@ from satori.model import Event, Meta, ModelBase, Opcode
42
41
 
43
42
  from .. import EventType
44
43
  from .adapter import Adapter as Adapter
45
- from .conection import WebsocketConnection
44
+ from .connection import WebsocketConnection
46
45
  from .formdata import parse_content_disposition as parse_content_disposition
47
46
  from .model import Provider as Provider
48
47
  from .model import Request as Request
@@ -52,6 +51,10 @@ from .route import RouteCall as RouteCall
52
51
  from .route import RouterMixin as RouterMixin
53
52
  from .utils import Deque
54
53
 
54
+ _T_endpoint = TypeVar("_T_endpoint", bound=Callable[[StarletteRequest], Awaitable[Response] | Response])
55
+ _T_ws_endpoint = TypeVar("_T_ws_endpoint", bound=Callable[[WebSocket], Awaitable[None]])
56
+ StarletteResponse = Response
57
+
55
58
 
56
59
  async def _request_handler(
57
60
  action: str, request: StarletteRequest, func: RouteCall, platform: str, self_id: str
@@ -115,13 +118,12 @@ class Server(Service, RouterMixin):
115
118
  stream_chunk_size: int = 64 * 1024,
116
119
  ):
117
120
  self.connections = []
118
- manager = it(Launart)
119
- manager.add_component(UvicornASGIService(host, port))
121
+ self.host = host
122
+ self.port = port
120
123
  self.version = version
121
124
  self.path = path
122
125
  if self.path and not self.path.startswith("/"):
123
126
  self.path = f"/{self.path}"
124
- self.url_base = f"http://{host}:{port}{self.path}/{version}"
125
127
  self.token = token
126
128
  self._adapters = []
127
129
  self.providers = []
@@ -134,8 +136,59 @@ class Server(Service, RouterMixin):
134
136
  self.stream_threshold = stream_threshold
135
137
  self.stream_chunk_size = stream_chunk_size
136
138
  self.resources: dict[str, Path] = {}
139
+ self.app = Starlette()
137
140
  super().__init__()
138
141
 
142
+ def replace_app(self, app: asgitypes.ASGI3Application):
143
+ """替换当前的 Starlette 应用"""
144
+ self.app = app
145
+
146
+ def asgi_route(
147
+ self,
148
+ path: str,
149
+ methods: list[str] | None = None,
150
+ name: str | None = None,
151
+ include_in_schema: bool = True,
152
+ ) -> Callable[[_T_endpoint], _T_endpoint]:
153
+ """注册一个 ASGI 路由
154
+
155
+ Args:
156
+ path (str): 路由路径
157
+ methods (list[str], optional): 支持的 HTTP 方法,默认为 None,表示 ["GET"]
158
+ name (str, optional): 路由名称,默认为 None
159
+ include_in_schema (bool, optional): 是否包含在 OpenAPI 文档中,默认为 True
160
+ """
161
+
162
+ def wrapper(endpoint: _T_endpoint, /) -> _T_endpoint:
163
+ self.app.add_route(
164
+ path, endpoint, methods=methods, name=name, include_in_schema=include_in_schema
165
+ )
166
+ return endpoint
167
+
168
+ return wrapper
169
+
170
+ def asgi_websocket_route(
171
+ self,
172
+ path: str,
173
+ name: str | None = None,
174
+ ) -> Callable[[_T_ws_endpoint], _T_ws_endpoint]:
175
+ """注册一个 ASGI WebSocket 路由
176
+
177
+ Args:
178
+ path (str): 路由路径
179
+ name (str, optional): 路由名称,默认为 None
180
+ """
181
+
182
+ def wrapper(endpoint: _T_ws_endpoint, /) -> _T_ws_endpoint:
183
+ self.app.add_websocket_route(path, endpoint, name=name)
184
+ return endpoint
185
+
186
+ return wrapper
187
+
188
+ @property
189
+ def url_base(self):
190
+ return f"http://{self.host}:{self.port}{self.path}/{self.version}"
191
+
139
192
  def apply(self, item: Provider | Router | Adapter):
140
193
  if isinstance(item, Adapter):
141
194
  item.ensure_server(self)
@@ -157,9 +210,11 @@ class Server(Service, RouterMixin):
157
210
  self._event_cache.append(event)
158
211
  self._sequence += 1
159
212
  for connection in self.connections:
213
+ if not connection.alive:
214
+ continue
160
215
  try:
161
216
  await connection.send({"op": Opcode.EVENT, "body": event.dump()})
162
- except WebSocketDisconnect:
217
+ except (WebSocketDisconnect, RuntimeError):
163
218
  break
164
219
  except Exception as e:
165
220
  print_exc()
@@ -287,13 +342,14 @@ class Server(Service, RouterMixin):
287
342
  file = Path(self._tempdir.name) / path[5:]
288
343
  if file.exists():
289
344
  return FileResponse(file)
290
- raise FileNotFoundError(f"{path[5:]} not found")
291
345
  assert request is not None
292
346
  for provider in self.providers:
293
347
  if provider.ensure(platform, self_id):
294
348
  return await provider.handle_internal(
295
349
  Request(request, "internal", {}, platform=platform, self_id=self_id), path
296
350
  )
351
+ if path.startswith("_tmp"):
352
+ raise FileNotFoundError(f"File not found: {path[5:]}")
297
353
  raise NotImplementedError(f"Login with {platform}:{self_id} not found")
298
354
  raise TypeError(f"Invalid internal url: {url}")
299
355
 
@@ -382,8 +438,8 @@ class Server(Service, RouterMixin):
382
438
 
383
439
  async with self.stage("preparing"):
384
440
  asgi_service = manager.get_component(UvicornASGIService)
385
- app = Starlette(
386
- routes=[
441
+ self.app.routes.extend(
442
+ [
387
443
  *chain.from_iterable(ada.get_routes() for ada in self._adapters),
388
444
  WebSocketRoute(f"{self.path}/{self.version}/events", self.websocket_server_handler),
389
445
  Route(
@@ -414,8 +470,8 @@ class Server(Service, RouterMixin):
414
470
  ]
415
471
  )
416
472
  for path, file in self.resources.items():
417
- app.mount(path, StaticFiles(directory=file.parent, html=file.suffix == ".html"))
418
- asgi_service.middleware.mounts[""] = app # type: ignore
473
+ self.app.mount(path, StaticFiles(directory=file.parent, html=file.suffix == ".html"))
474
+ asgi_service.middleware.mounts[""] = self.app # type: ignore
419
475
 
420
476
  async def event_task(_provider: Provider):
421
477
  async for event in _provider.publisher():
@@ -457,7 +513,10 @@ class Server(Service, RouterMixin):
457
513
  ):
458
514
  if manager is None:
459
515
  manager = it(Launart)
516
+ manager.add_component(UvicornASGIService(self.host, self.port))
460
517
  manager.add_component(self)
518
+ with suppress(ValueError):
519
+ manager.add_component(AiohttpClientService())
461
520
  manager.launch_blocking(loop=loop, stop_signal=stop_signal)
462
521
 
463
522
  async def run_async(
@@ -88,20 +88,20 @@ class ChannelListParam(TypedDict):
88
88
  CHANNEL_LIST: TypeAlias = RouteCall[ChannelListParam, Union[PageResult[Channel], dict[str, Any]]]
89
89
 
90
90
 
91
- class ChanneCreateParam(TypedDict):
91
+ class ChannelCreateParam(TypedDict):
92
92
  guild_id: str
93
93
  data: dict
94
94
 
95
95
 
96
- CHANNEL_CREATE: TypeAlias = RouteCall[ChanneCreateParam, Union[Channel, dict[str, Any]]]
96
+ CHANNEL_CREATE: TypeAlias = RouteCall[ChannelCreateParam, Union[Channel, dict[str, Any]]]
97
97
 
98
98
 
99
- class ChanneUpdateParam(TypedDict):
99
+ class ChannelUpdateParam(TypedDict):
100
100
  channel_id: str
101
101
  data: dict
102
102
 
103
103
 
104
- CHANNEL_UPDATE: TypeAlias = RouteCall[ChanneUpdateParam, None]
104
+ CHANNEL_UPDATE: TypeAlias = RouteCall[ChannelUpdateParam, None]
105
105
 
106
106
 
107
107
  class ChannelMuteParam(TypedDict):
@@ -261,7 +261,7 @@ FRIEND_LIST: TypeAlias = RouteCall[FriendListParam, Union[PageResult[User], dict
261
261
  class ApproveParam(TypedDict):
262
262
  message_id: str
263
263
  approve: bool
264
- comment: str
264
+ comment: NotRequired[str]
265
265
 
266
266
 
267
267
  APPROVE: TypeAlias = RouteCall[ApproveParam, None]
@@ -402,7 +402,7 @@ class RouterMixin:
402
402
  def route(self, path: str) -> Callable[[INTERAL], INTERAL]: ...
403
403
 
404
404
  def route(self, path: Union[str, Api]) -> Callable[[RouteCall], RouteCall]:
405
- """注册一个路由
405
+ """注册一个 Satori 路由
406
406
 
407
407
  Args:
408
408
  path (str | Api): 路由路径;若 path 不属于 Api,则会被认为是内部接口
File without changes