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 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
@@ -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}")