feishulib 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- feishulib/__init__.py +61 -0
- feishulib/auth.py +68 -0
- feishulib/channel.py +107 -0
- feishulib/client.py +191 -0
- feishulib/config.py +55 -0
- feishulib/events.py +146 -0
- feishulib/exceptions.py +60 -0
- feishulib/http.py +199 -0
- feishulib/models.py +123 -0
- feishulib/proto/__init__.py +0 -0
- feishulib/proto/frame.proto +20 -0
- feishulib/proto/frame_pb2.py +38 -0
- feishulib/protocol.py +111 -0
- feishulib/py.typed +0 -0
- feishulib/websocket.py +248 -0
- feishulib-0.1.2.dist-info/METADATA +261 -0
- feishulib-0.1.2.dist-info/RECORD +18 -0
- feishulib-0.1.2.dist-info/WHEEL +4 -0
feishulib/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Async Pythonic client for selected Feishu IM capabilities."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from feishulib.config import FeishuConfig
|
|
6
|
+
from feishulib.client import FeishuClient
|
|
7
|
+
from feishulib.channel import EventChannel
|
|
8
|
+
from feishulib.events import CardActionEvent, MessageEvent, OperatorIdentity, SenderIdentity
|
|
9
|
+
from feishulib.exceptions import (
|
|
10
|
+
FeishuApiError,
|
|
11
|
+
FeishuAuthError,
|
|
12
|
+
FeishuError,
|
|
13
|
+
FeishuEventHandlerError,
|
|
14
|
+
FeishuEventParseError,
|
|
15
|
+
FeishuHttpStatusError,
|
|
16
|
+
FeishuProtocolError,
|
|
17
|
+
FeishuTransientError,
|
|
18
|
+
FeishuWebSocketError,
|
|
19
|
+
)
|
|
20
|
+
from feishulib.models import (
|
|
21
|
+
BinaryResponse,
|
|
22
|
+
BotIdentity,
|
|
23
|
+
CardActionResponse,
|
|
24
|
+
CardUpdate,
|
|
25
|
+
MessageReceipt,
|
|
26
|
+
OutboundMessage,
|
|
27
|
+
ReplyMessage,
|
|
28
|
+
Toast,
|
|
29
|
+
UpdateMessage,
|
|
30
|
+
)
|
|
31
|
+
from feishulib.websocket import FeishuWebSocket
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"BinaryResponse",
|
|
35
|
+
"BotIdentity",
|
|
36
|
+
"CardActionResponse",
|
|
37
|
+
"CardUpdate",
|
|
38
|
+
"CardActionEvent",
|
|
39
|
+
"EventChannel",
|
|
40
|
+
"FeishuApiError",
|
|
41
|
+
"FeishuAuthError",
|
|
42
|
+
"FeishuConfig",
|
|
43
|
+
"FeishuClient",
|
|
44
|
+
"FeishuError",
|
|
45
|
+
"FeishuEventHandlerError",
|
|
46
|
+
"FeishuEventParseError",
|
|
47
|
+
"FeishuHttpStatusError",
|
|
48
|
+
"FeishuProtocolError",
|
|
49
|
+
"FeishuTransientError",
|
|
50
|
+
"FeishuWebSocketError",
|
|
51
|
+
"MessageReceipt",
|
|
52
|
+
"MessageEvent",
|
|
53
|
+
"OperatorIdentity",
|
|
54
|
+
"OutboundMessage",
|
|
55
|
+
"ReplyMessage",
|
|
56
|
+
"SenderIdentity",
|
|
57
|
+
"Toast",
|
|
58
|
+
"UpdateMessage",
|
|
59
|
+
"FeishuWebSocket",
|
|
60
|
+
"__version__",
|
|
61
|
+
]
|
feishulib/auth.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Tenant access token caching and refresh coordination."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from feishulib.config import FeishuConfig
|
|
8
|
+
from feishulib.exceptions import FeishuAuthError, FeishuError
|
|
9
|
+
from feishulib.http import FeishuHttpClient
|
|
10
|
+
from feishulib.models import JsonValue
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TenantAccessTokenManager:
|
|
14
|
+
"""Return cached tenant tokens and collapse concurrent refreshes."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
config: FeishuConfig,
|
|
19
|
+
http: FeishuHttpClient,
|
|
20
|
+
*,
|
|
21
|
+
clock: Callable[[], float] = time.monotonic,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._config = config
|
|
24
|
+
self._http = http
|
|
25
|
+
self._clock = clock
|
|
26
|
+
self._lock = asyncio.Lock()
|
|
27
|
+
self._token: str | None = None
|
|
28
|
+
self._expires_at = 0.0
|
|
29
|
+
|
|
30
|
+
async def get_token(self, *, force_refresh: bool = False) -> str:
|
|
31
|
+
"""Return a valid tenant access token, refreshing it once when needed."""
|
|
32
|
+
observed_token = self._token
|
|
33
|
+
if not force_refresh and self._is_fresh():
|
|
34
|
+
return self._token_or_error()
|
|
35
|
+
async with self._lock:
|
|
36
|
+
if not force_refresh and self._is_fresh():
|
|
37
|
+
return self._token_or_error()
|
|
38
|
+
if force_refresh and self._token != observed_token and self._is_fresh():
|
|
39
|
+
return self._token_or_error()
|
|
40
|
+
return await self._refresh()
|
|
41
|
+
|
|
42
|
+
def _is_fresh(self) -> bool:
|
|
43
|
+
return self._token is not None and self._clock() < self._expires_at - self._config.token_refresh_skew_seconds
|
|
44
|
+
|
|
45
|
+
def _token_or_error(self) -> str:
|
|
46
|
+
if self._token is None:
|
|
47
|
+
raise FeishuAuthError("tenant token cache was unexpectedly empty")
|
|
48
|
+
return self._token
|
|
49
|
+
|
|
50
|
+
async def _refresh(self) -> str:
|
|
51
|
+
body: dict[str, JsonValue] = {"app_id": self._config.app_id, "app_secret": self._config.app_secret}
|
|
52
|
+
try:
|
|
53
|
+
response = await self._http.request_json(
|
|
54
|
+
"POST",
|
|
55
|
+
"/open-apis/auth/v3/tenant_access_token/internal",
|
|
56
|
+
json_body=body,
|
|
57
|
+
)
|
|
58
|
+
token = response.data.get("tenant_access_token")
|
|
59
|
+
expires_in = response.data.get("expire")
|
|
60
|
+
if not isinstance(token, str) or not token:
|
|
61
|
+
raise ValueError("tenant token is missing or invalid")
|
|
62
|
+
if not isinstance(expires_in, int) or isinstance(expires_in, bool) or expires_in <= 0:
|
|
63
|
+
raise ValueError("token expiry is missing or invalid")
|
|
64
|
+
except (FeishuError, ValueError) as error:
|
|
65
|
+
raise FeishuAuthError("tenant token refresh failed") from error
|
|
66
|
+
self._token = token
|
|
67
|
+
self._expires_at = self._clock() + expires_in
|
|
68
|
+
return token
|
feishulib/channel.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Bounded worker dispatch for parsed Feishu events."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from feishulib.config import FeishuConfig
|
|
8
|
+
from feishulib.events import CardActionEvent, MessageEvent, parse_event_payload
|
|
9
|
+
from feishulib.exceptions import FeishuEventHandlerError
|
|
10
|
+
from feishulib.models import CardActionResponse
|
|
11
|
+
|
|
12
|
+
type EventHandler[TEvent, TResult] = Callable[[TEvent], Awaitable[TResult]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class _DispatchItem:
|
|
17
|
+
event: MessageEvent | CardActionEvent
|
|
18
|
+
result: asyncio.Future[CardActionResponse | None]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EventChannel:
|
|
22
|
+
"""Dispatch parsed events outside of the WebSocket receive loop."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: FeishuConfig) -> None:
|
|
25
|
+
self._config = config
|
|
26
|
+
self._message_handlers: list[EventHandler[MessageEvent, None]] = []
|
|
27
|
+
self._card_handler: EventHandler[CardActionEvent, CardActionResponse | None] | None = None
|
|
28
|
+
self._queue: asyncio.Queue[_DispatchItem] = asyncio.Queue(maxsize=config.event_queue_size)
|
|
29
|
+
self._workers: list[asyncio.Task[None]] = []
|
|
30
|
+
self._started = False
|
|
31
|
+
self._closed = False
|
|
32
|
+
|
|
33
|
+
def on(self, name: str, handler: Callable[..., Awaitable[object]]) -> None:
|
|
34
|
+
"""Register an asynchronous event handler."""
|
|
35
|
+
if self._closed:
|
|
36
|
+
raise RuntimeError("event channel is closed")
|
|
37
|
+
if name == "message":
|
|
38
|
+
self._message_handlers.append(handler) # type: ignore[arg-type]
|
|
39
|
+
return
|
|
40
|
+
if name == "card_action":
|
|
41
|
+
if self._card_handler is not None:
|
|
42
|
+
raise ValueError("only one card_action handler is allowed")
|
|
43
|
+
self._card_handler = handler # type: ignore[assignment]
|
|
44
|
+
return
|
|
45
|
+
raise ValueError("unknown event name")
|
|
46
|
+
|
|
47
|
+
async def start(self) -> None:
|
|
48
|
+
"""Start the configured worker pool."""
|
|
49
|
+
if self._closed:
|
|
50
|
+
raise RuntimeError("event channel is closed")
|
|
51
|
+
if not self._started:
|
|
52
|
+
self._started = True
|
|
53
|
+
self._workers = [asyncio.create_task(self._worker()) for _ in range(self._config.event_worker_count)]
|
|
54
|
+
|
|
55
|
+
async def dispatch(self, payload: bytes) -> CardActionResponse | None:
|
|
56
|
+
"""Parse, enqueue, and await one event's handler result."""
|
|
57
|
+
if not self._started or self._closed:
|
|
58
|
+
raise RuntimeError("event channel is not running")
|
|
59
|
+
event = parse_event_payload(payload)
|
|
60
|
+
result: asyncio.Future[CardActionResponse | None] = asyncio.get_running_loop().create_future()
|
|
61
|
+
try:
|
|
62
|
+
self._queue.put_nowait(_DispatchItem(event, result))
|
|
63
|
+
except asyncio.QueueFull as error:
|
|
64
|
+
raise FeishuEventHandlerError("queue", error) from error
|
|
65
|
+
return await result
|
|
66
|
+
|
|
67
|
+
async def close(self) -> None:
|
|
68
|
+
"""Cancel workers and reject subsequent dispatches."""
|
|
69
|
+
if self._closed:
|
|
70
|
+
return
|
|
71
|
+
self._closed = True
|
|
72
|
+
for worker in self._workers:
|
|
73
|
+
worker.cancel()
|
|
74
|
+
if self._workers:
|
|
75
|
+
await asyncio.gather(*self._workers, return_exceptions=True)
|
|
76
|
+
while not self._queue.empty():
|
|
77
|
+
item = self._queue.get_nowait()
|
|
78
|
+
if not item.result.done():
|
|
79
|
+
item.result.set_exception(FeishuEventHandlerError("shutdown", RuntimeError("channel closed")))
|
|
80
|
+
|
|
81
|
+
async def _worker(self) -> None:
|
|
82
|
+
while True:
|
|
83
|
+
item = await self._queue.get()
|
|
84
|
+
try:
|
|
85
|
+
if isinstance(item.event, MessageEvent):
|
|
86
|
+
for handler in self._message_handlers:
|
|
87
|
+
await handler(item.event)
|
|
88
|
+
if not item.result.done():
|
|
89
|
+
item.result.set_result(None)
|
|
90
|
+
elif self._card_handler is None:
|
|
91
|
+
if not item.result.done():
|
|
92
|
+
item.result.set_result(None)
|
|
93
|
+
else:
|
|
94
|
+
response = await self._card_handler(item.event)
|
|
95
|
+
if response is not None and not isinstance(response, CardActionResponse): # type: ignore[unnecessary-isinstance]
|
|
96
|
+
raise TypeError("card_action handler must return CardActionResponse or None")
|
|
97
|
+
if not item.result.done():
|
|
98
|
+
item.result.set_result(response)
|
|
99
|
+
except asyncio.CancelledError as error:
|
|
100
|
+
if not item.result.done():
|
|
101
|
+
item.result.set_exception(FeishuEventHandlerError("shutdown", error))
|
|
102
|
+
raise
|
|
103
|
+
except Exception as error:
|
|
104
|
+
if not item.result.done():
|
|
105
|
+
item.result.set_exception(FeishuEventHandlerError("handler", error))
|
|
106
|
+
finally:
|
|
107
|
+
self._queue.task_done()
|
feishulib/client.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""High-level asynchronous Feishu IM and bot client."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from typing import Literal, Self
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from feishulib.auth import TenantAccessTokenManager
|
|
12
|
+
from feishulib.config import FeishuConfig
|
|
13
|
+
from feishulib.exceptions import FeishuApiError, FeishuHttpStatusError
|
|
14
|
+
from feishulib.http import FeishuHttpClient
|
|
15
|
+
from feishulib.models import (
|
|
16
|
+
ApiResponse,
|
|
17
|
+
BinaryResponse,
|
|
18
|
+
BotIdentity,
|
|
19
|
+
JsonValue,
|
|
20
|
+
MessageReceipt,
|
|
21
|
+
OutboundMessage,
|
|
22
|
+
ReceiveIdType,
|
|
23
|
+
ReplyMessage,
|
|
24
|
+
UpdateMessage,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FeishuClient:
|
|
29
|
+
"""Facade for the selected Feishu IM REST surface."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, config: FeishuConfig, *, session: httpx.AsyncClient | None = None) -> None:
|
|
32
|
+
self.config = config
|
|
33
|
+
self._owns_session = session is None
|
|
34
|
+
self._session = session if session is not None else httpx.AsyncClient()
|
|
35
|
+
self._http = FeishuHttpClient(config, self._session)
|
|
36
|
+
self._tokens = TenantAccessTokenManager(config, self._http)
|
|
37
|
+
|
|
38
|
+
async def __aenter__(self) -> Self:
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
42
|
+
await self.aclose()
|
|
43
|
+
|
|
44
|
+
async def aclose(self) -> None:
|
|
45
|
+
if self._owns_session:
|
|
46
|
+
await self._session.aclose()
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _outbound_with_uuid(message: OutboundMessage) -> OutboundMessage:
|
|
50
|
+
if message.uuid is not None:
|
|
51
|
+
return message
|
|
52
|
+
return replace(message, uuid=str(uuid4()))
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _reply_with_uuid(message: ReplyMessage) -> ReplyMessage:
|
|
56
|
+
if message.uuid is not None:
|
|
57
|
+
return message
|
|
58
|
+
return replace(message, uuid=str(uuid4()))
|
|
59
|
+
|
|
60
|
+
async def send_message(self, message: OutboundMessage) -> MessageReceipt:
|
|
61
|
+
message = self._outbound_with_uuid(message)
|
|
62
|
+
response = await self._authorized_json(
|
|
63
|
+
"POST",
|
|
64
|
+
"/open-apis/im/v1/messages",
|
|
65
|
+
params={"receive_id_type": message.receive_id_type},
|
|
66
|
+
json_body=message.to_payload(),
|
|
67
|
+
)
|
|
68
|
+
return self._receipt(response)
|
|
69
|
+
|
|
70
|
+
async def send_text(
|
|
71
|
+
self,
|
|
72
|
+
receive_id: str,
|
|
73
|
+
text: str,
|
|
74
|
+
*,
|
|
75
|
+
receive_id_type: ReceiveIdType = "chat_id",
|
|
76
|
+
uuid: str | None = None,
|
|
77
|
+
) -> MessageReceipt:
|
|
78
|
+
return await self.send_message(OutboundMessage(receive_id, receive_id_type, "text", {"text": text}, uuid))
|
|
79
|
+
|
|
80
|
+
async def send_card(
|
|
81
|
+
self,
|
|
82
|
+
receive_id: str,
|
|
83
|
+
card: Mapping[str, JsonValue],
|
|
84
|
+
*,
|
|
85
|
+
receive_id_type: ReceiveIdType = "chat_id",
|
|
86
|
+
uuid: str | None = None,
|
|
87
|
+
) -> MessageReceipt:
|
|
88
|
+
return await self.send_message(OutboundMessage(receive_id, receive_id_type, "interactive", card, uuid))
|
|
89
|
+
|
|
90
|
+
async def reply_message(self, message: ReplyMessage) -> MessageReceipt:
|
|
91
|
+
message = self._reply_with_uuid(message)
|
|
92
|
+
response = await self._authorized_json(
|
|
93
|
+
"POST",
|
|
94
|
+
f"/open-apis/im/v1/messages/{quote(message.message_id, safe='')}/reply",
|
|
95
|
+
json_body=message.to_payload(),
|
|
96
|
+
)
|
|
97
|
+
return self._receipt(response)
|
|
98
|
+
|
|
99
|
+
async def reply_text(
|
|
100
|
+
self,
|
|
101
|
+
message_id: str,
|
|
102
|
+
text: str,
|
|
103
|
+
*,
|
|
104
|
+
reply_in_thread: bool = False,
|
|
105
|
+
uuid: str | None = None,
|
|
106
|
+
) -> MessageReceipt:
|
|
107
|
+
return await self.reply_message(ReplyMessage(message_id, "text", {"text": text}, reply_in_thread, uuid))
|
|
108
|
+
|
|
109
|
+
async def update_message(self, message: UpdateMessage) -> MessageReceipt:
|
|
110
|
+
response = await self._authorized_json(
|
|
111
|
+
"PUT",
|
|
112
|
+
f"/open-apis/im/v1/messages/{quote(message.message_id, safe='')}",
|
|
113
|
+
json_body=message.to_payload(),
|
|
114
|
+
)
|
|
115
|
+
return self._receipt(response)
|
|
116
|
+
|
|
117
|
+
async def update_card(self, message_id: str, card: Mapping[str, JsonValue]) -> MessageReceipt:
|
|
118
|
+
return await self.update_message(UpdateMessage(message_id, "interactive", card))
|
|
119
|
+
|
|
120
|
+
async def delete_message(self, message_id: str) -> None:
|
|
121
|
+
await self._authorized_json("DELETE", f"/open-apis/im/v1/messages/{quote(message_id, safe='')}")
|
|
122
|
+
|
|
123
|
+
async def download_file(
|
|
124
|
+
self,
|
|
125
|
+
message_id: str,
|
|
126
|
+
file_key: str,
|
|
127
|
+
*,
|
|
128
|
+
resource_type: Literal["file", "image"] = "file",
|
|
129
|
+
) -> bytes:
|
|
130
|
+
response = await self._authorized_bytes(
|
|
131
|
+
"GET",
|
|
132
|
+
f"/open-apis/im/v1/messages/{quote(message_id, safe='')}/resources/{quote(file_key, safe='')}",
|
|
133
|
+
params={"type": resource_type},
|
|
134
|
+
)
|
|
135
|
+
return response.content
|
|
136
|
+
|
|
137
|
+
async def get_bot_identity(self) -> BotIdentity:
|
|
138
|
+
response = await self._authorized_json("GET", "/open-apis/bot/v3/info")
|
|
139
|
+
candidate = response.data.get("bot")
|
|
140
|
+
if not isinstance(candidate, Mapping):
|
|
141
|
+
candidate = response.data
|
|
142
|
+
open_id = candidate.get("open_id")
|
|
143
|
+
if not isinstance(open_id, str) or not open_id:
|
|
144
|
+
raise FeishuApiError(0, "bot identity response has no open_id", response.request_id)
|
|
145
|
+
return BotIdentity(open_id)
|
|
146
|
+
|
|
147
|
+
async def _authorized_json(
|
|
148
|
+
self,
|
|
149
|
+
method: str,
|
|
150
|
+
path: str,
|
|
151
|
+
*,
|
|
152
|
+
params: Mapping[str, str] | None = None,
|
|
153
|
+
json_body: Mapping[str, JsonValue] | None = None,
|
|
154
|
+
) -> ApiResponse:
|
|
155
|
+
token = await self._tokens.get_token()
|
|
156
|
+
try:
|
|
157
|
+
return await self._http.request_json(method, path, headers={"Authorization": f"Bearer {token}"}, params=params, json_body=json_body)
|
|
158
|
+
except FeishuHttpStatusError as error:
|
|
159
|
+
if error.status_code != 401:
|
|
160
|
+
raise
|
|
161
|
+
token = await self._tokens.get_token(force_refresh=True)
|
|
162
|
+
return await self._http.request_json(method, path, headers={"Authorization": f"Bearer {token}"}, params=params, json_body=json_body)
|
|
163
|
+
|
|
164
|
+
async def _authorized_bytes(
|
|
165
|
+
self,
|
|
166
|
+
method: str,
|
|
167
|
+
path: str,
|
|
168
|
+
*,
|
|
169
|
+
params: Mapping[str, str] | None = None,
|
|
170
|
+
) -> BinaryResponse:
|
|
171
|
+
token = await self._tokens.get_token()
|
|
172
|
+
try:
|
|
173
|
+
return await self._http.request_bytes(method, path, headers={"Authorization": f"Bearer {token}"}, params=params)
|
|
174
|
+
except FeishuHttpStatusError as error:
|
|
175
|
+
if error.status_code != 401:
|
|
176
|
+
raise
|
|
177
|
+
token = await self._tokens.get_token(force_refresh=True)
|
|
178
|
+
return await self._http.request_bytes(method, path, headers={"Authorization": f"Bearer {token}"}, params=params)
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _receipt(response: ApiResponse) -> MessageReceipt:
|
|
182
|
+
message_id = response.data.get("message_id")
|
|
183
|
+
if not isinstance(message_id, str) or not message_id:
|
|
184
|
+
raise FeishuApiError(0, "message response has no message_id", response.request_id)
|
|
185
|
+
root_id = response.data.get("root_id")
|
|
186
|
+
parent_id = response.data.get("parent_id")
|
|
187
|
+
return MessageReceipt(
|
|
188
|
+
message_id,
|
|
189
|
+
root_id if isinstance(root_id, str) else None,
|
|
190
|
+
parent_id if isinstance(parent_id, str) else None,
|
|
191
|
+
)
|
feishulib/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Runtime configuration for the Feishu IM client."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class FeishuConfig:
|
|
8
|
+
app_id: str
|
|
9
|
+
app_secret: str = field(repr=False)
|
|
10
|
+
base_url: str = "https://open.feishu.cn"
|
|
11
|
+
request_timeout_seconds: float = 10.0
|
|
12
|
+
max_retries: int = 3
|
|
13
|
+
retry_backoff_base_seconds: float = 0.5
|
|
14
|
+
retry_max_delay_seconds: float = 15.0
|
|
15
|
+
retry_jitter_ratio: float = 0.1
|
|
16
|
+
token_refresh_skew_seconds: float = 60.0
|
|
17
|
+
ws_open_timeout_seconds: float = 15.0
|
|
18
|
+
ws_close_timeout_seconds: float = 10.0
|
|
19
|
+
ws_ping_timeout_seconds: float = 180.0
|
|
20
|
+
ws_reconnect_base_seconds: float = 1.0
|
|
21
|
+
ws_reconnect_max_seconds: float = 60.0
|
|
22
|
+
ws_reconnect_jitter_ratio: float = 0.1
|
|
23
|
+
event_queue_size: int = 100
|
|
24
|
+
event_worker_count: int = 1
|
|
25
|
+
card_action_timeout_seconds: float = 8.0
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if not self.app_id:
|
|
29
|
+
raise ValueError("app_id must not be empty")
|
|
30
|
+
if not self.app_secret:
|
|
31
|
+
raise ValueError("app_secret must not be empty")
|
|
32
|
+
if not self.base_url.startswith("https://"):
|
|
33
|
+
raise ValueError("base_url must use https")
|
|
34
|
+
if self.request_timeout_seconds <= 0:
|
|
35
|
+
raise ValueError("request_timeout_seconds must be positive")
|
|
36
|
+
if self.max_retries < 0:
|
|
37
|
+
raise ValueError("max_retries must be non-negative")
|
|
38
|
+
if self.retry_backoff_base_seconds <= 0 or self.retry_max_delay_seconds <= 0:
|
|
39
|
+
raise ValueError("retry delays must be positive")
|
|
40
|
+
if not 0 <= self.retry_jitter_ratio <= 1:
|
|
41
|
+
raise ValueError("retry_jitter_ratio must be between 0 and 1")
|
|
42
|
+
if self.token_refresh_skew_seconds < 0:
|
|
43
|
+
raise ValueError("token_refresh_skew_seconds must be non-negative")
|
|
44
|
+
if self.ws_open_timeout_seconds <= 0 or self.ws_close_timeout_seconds <= 0:
|
|
45
|
+
raise ValueError("websocket timeouts must be positive")
|
|
46
|
+
if self.ws_ping_timeout_seconds <= 0:
|
|
47
|
+
raise ValueError("ws_ping_timeout_seconds must be positive")
|
|
48
|
+
if self.ws_reconnect_base_seconds <= 0 or self.ws_reconnect_max_seconds <= 0:
|
|
49
|
+
raise ValueError("websocket reconnect delays must be positive")
|
|
50
|
+
if not 0 <= self.ws_reconnect_jitter_ratio <= 1:
|
|
51
|
+
raise ValueError("ws_reconnect_jitter_ratio must be between 0 and 1")
|
|
52
|
+
if self.event_queue_size < 1 or self.event_worker_count < 1:
|
|
53
|
+
raise ValueError("event queue size and worker count must be positive")
|
|
54
|
+
if self.card_action_timeout_seconds <= 0:
|
|
55
|
+
raise ValueError("card_action_timeout_seconds must be positive")
|
feishulib/events.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Schema 2.0 event parsing with an explicit trusted identity boundary."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from feishulib.exceptions import FeishuEventParseError
|
|
9
|
+
from feishulib.models import JsonValue
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class SenderIdentity:
|
|
14
|
+
open_id: str | None
|
|
15
|
+
user_id: str | None
|
|
16
|
+
union_id: str | None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class OperatorIdentity:
|
|
21
|
+
tenant_key: str | None
|
|
22
|
+
user_id: str | None
|
|
23
|
+
open_id: str
|
|
24
|
+
union_id: str | None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class MessageEvent:
|
|
29
|
+
event_id: str | None
|
|
30
|
+
sender: SenderIdentity
|
|
31
|
+
message_id: str
|
|
32
|
+
chat_id: str | None
|
|
33
|
+
chat_type: str | None
|
|
34
|
+
message_type: str | None
|
|
35
|
+
raw_content: str | None
|
|
36
|
+
content: Mapping[str, JsonValue] | None
|
|
37
|
+
text: str | None
|
|
38
|
+
file_key: str | None
|
|
39
|
+
root_id: str | None
|
|
40
|
+
parent_id: str | None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class CardActionEvent:
|
|
45
|
+
event_id: str | None
|
|
46
|
+
operator: OperatorIdentity
|
|
47
|
+
action_value: Mapping[str, JsonValue]
|
|
48
|
+
action_tag: str | None
|
|
49
|
+
action_name: str | None
|
|
50
|
+
form_value: Mapping[str, JsonValue] | None
|
|
51
|
+
token: str | None
|
|
52
|
+
message_id: str | None
|
|
53
|
+
chat_id: str | None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_event_payload(payload: bytes) -> MessageEvent | CardActionEvent:
|
|
57
|
+
"""Parse one trusted Feishu schema 2.0 event payload."""
|
|
58
|
+
try:
|
|
59
|
+
body = json.loads(payload.decode("utf-8"))
|
|
60
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
61
|
+
raise FeishuEventParseError("event payload is not valid UTF-8 JSON") from error
|
|
62
|
+
if not isinstance(body, Mapping):
|
|
63
|
+
raise FeishuEventParseError("event schema must be 2.0")
|
|
64
|
+
envelope = cast(Mapping[str, object], body)
|
|
65
|
+
if envelope.get("schema") != "2.0":
|
|
66
|
+
raise FeishuEventParseError("event schema must be 2.0")
|
|
67
|
+
header = _mapping(envelope.get("header"), "header")
|
|
68
|
+
event_type = header.get("event_type")
|
|
69
|
+
if not isinstance(event_type, str):
|
|
70
|
+
raise FeishuEventParseError("header.event_type must be a string")
|
|
71
|
+
event = _mapping(envelope.get("event"), "event", event_type)
|
|
72
|
+
if event_type == "im.message.receive_v1":
|
|
73
|
+
return _message_event(header, event)
|
|
74
|
+
if event_type == "card.action.trigger":
|
|
75
|
+
return _card_event(header, event)
|
|
76
|
+
raise FeishuEventParseError(f"{event_type}: unsupported event type")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _message_event(header: Mapping[str, object], event: Mapping[str, object]) -> MessageEvent:
|
|
80
|
+
message = _mapping(event.get("message"), "event.message", "im.message.receive_v1")
|
|
81
|
+
sender = _mapping(event.get("sender"), "event.sender", "im.message.receive_v1")
|
|
82
|
+
ids = _mapping(sender.get("sender_id"), "event.sender.sender_id", "im.message.receive_v1")
|
|
83
|
+
message_id = message.get("message_id")
|
|
84
|
+
if not isinstance(message_id, str) or not message_id:
|
|
85
|
+
raise FeishuEventParseError("im.message.receive_v1: event.message.message_id is required")
|
|
86
|
+
raw_content = message.get("content")
|
|
87
|
+
raw = raw_content if isinstance(raw_content, str) else None
|
|
88
|
+
content = _content(raw)
|
|
89
|
+
text = content.get("text") if content is not None else None
|
|
90
|
+
file_key = content.get("file_key") if content is not None else None
|
|
91
|
+
return MessageEvent(
|
|
92
|
+
_optional_str(header.get("event_id")),
|
|
93
|
+
SenderIdentity(_optional_str(ids.get("open_id")), _optional_str(ids.get("user_id")), _optional_str(ids.get("union_id"))),
|
|
94
|
+
message_id,
|
|
95
|
+
_optional_str(message.get("chat_id")),
|
|
96
|
+
_optional_str(message.get("chat_type")),
|
|
97
|
+
_optional_str(message.get("message_type")),
|
|
98
|
+
raw,
|
|
99
|
+
content,
|
|
100
|
+
text if isinstance(text, str) else None,
|
|
101
|
+
file_key if isinstance(file_key, str) else None,
|
|
102
|
+
_optional_str(message.get("root_id")),
|
|
103
|
+
_optional_str(message.get("parent_id")),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _card_event(header: Mapping[str, object], event: Mapping[str, object]) -> CardActionEvent:
|
|
108
|
+
operator = _mapping(event.get("operator"), "event.operator", "card.action.trigger")
|
|
109
|
+
open_id = operator.get("open_id")
|
|
110
|
+
if not isinstance(open_id, str) or not open_id:
|
|
111
|
+
raise FeishuEventParseError("card.action.trigger: event.operator.open_id is required")
|
|
112
|
+
action = _mapping(event.get("action"), "event.action", "card.action.trigger")
|
|
113
|
+
value = action.get("value", {})
|
|
114
|
+
action_value = cast(Mapping[str, JsonValue], _mapping(value, "event.action.value", "card.action.trigger"))
|
|
115
|
+
form_value = action.get("form_value")
|
|
116
|
+
return CardActionEvent(
|
|
117
|
+
_optional_str(header.get("event_id")),
|
|
118
|
+
OperatorIdentity(_optional_str(operator.get("tenant_key")), _optional_str(operator.get("user_id")), open_id, _optional_str(operator.get("union_id"))),
|
|
119
|
+
dict(action_value),
|
|
120
|
+
_optional_str(action.get("tag")),
|
|
121
|
+
_optional_str(action.get("name")),
|
|
122
|
+
cast(Mapping[str, JsonValue], form_value) if isinstance(form_value, Mapping) else None,
|
|
123
|
+
_optional_str(event.get("token")),
|
|
124
|
+
_optional_str(event.get("open_message_id")),
|
|
125
|
+
_optional_str(event.get("open_chat_id")),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _content(raw_content: str | None) -> Mapping[str, JsonValue] | None:
|
|
130
|
+
if raw_content is None:
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
value = json.loads(raw_content)
|
|
134
|
+
except json.JSONDecodeError:
|
|
135
|
+
return None
|
|
136
|
+
return cast(Mapping[str, JsonValue], value) if isinstance(value, Mapping) else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _mapping(value: object, field: str, event_type: str = "event") -> Mapping[str, object]:
|
|
140
|
+
if not isinstance(value, Mapping):
|
|
141
|
+
raise FeishuEventParseError(f"{event_type}: {field} must be an object")
|
|
142
|
+
return cast(Mapping[str, object], value)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _optional_str(value: object) -> str | None:
|
|
146
|
+
return value if isinstance(value, str) else None
|
feishulib/exceptions.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Explicit exceptions raised by the Feishu IM client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FeishuError(Exception):
|
|
5
|
+
"""Base exception for all client failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FeishuAuthError(FeishuError):
|
|
9
|
+
"""Tenant access token retrieval or validation failed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FeishuApiError(FeishuError):
|
|
13
|
+
"""The Feishu API returned a non-zero business code."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, code: int, message: str, request_id: str | None = None) -> None:
|
|
16
|
+
self.code = code
|
|
17
|
+
self.message = message
|
|
18
|
+
self.request_id = request_id
|
|
19
|
+
super().__init__(f"Feishu API error {code}: {message}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FeishuHttpStatusError(FeishuError):
|
|
23
|
+
"""An HTTP response had a non-success status."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status_code: int, message: str, request_id: str | None = None) -> None:
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.message = message
|
|
28
|
+
self.request_id = request_id
|
|
29
|
+
super().__init__(f"Feishu HTTP status {status_code}: {message}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FeishuTransientError(FeishuError):
|
|
33
|
+
"""A retryable transport or HTTP failure exhausted its retry budget."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, status_code: int | None, attempts: int, request_id: str | None = None) -> None:
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
self.attempts = attempts
|
|
38
|
+
self.request_id = request_id
|
|
39
|
+
super().__init__(f"Feishu transient failure after {attempts} attempts")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FeishuProtocolError(FeishuError):
|
|
43
|
+
"""A remote response did not match the expected protocol."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FeishuWebSocketError(FeishuError):
|
|
47
|
+
"""A WebSocket connection or frame exchange failed."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FeishuEventParseError(FeishuError):
|
|
51
|
+
"""An incoming event could not be parsed safely."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FeishuEventHandlerError(FeishuError):
|
|
55
|
+
"""An event handler failed or event dispatch could not proceed."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, event_type: str, cause: BaseException) -> None:
|
|
58
|
+
self.event_type = event_type
|
|
59
|
+
self.cause = cause
|
|
60
|
+
super().__init__(f"Feishu event handler failed for {event_type}: {cause}")
|