feishulib 0.1.2__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,261 @@
1
+ Metadata-Version: 2.3
2
+ Name: feishulib
3
+ Version: 0.1.2
4
+ Summary: Async Pythonic Feishu IM client
5
+ Author: morrisx
6
+ Author-email: morrisx <ifme.in@gmail.com>
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: protobuf>=7.35.0,<8
9
+ Requires-Dist: websockets>=14
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
13
+ # feishulib
14
+
15
+ A lightweight, asynchronous, typed Python client for the Feishu IM API. No runtime dependency on `lark_oapi`.
16
+
17
+ ## Features
18
+
19
+ - **REST API** — send, reply, update, delete messages; download resources; query bot identity
20
+ - **Long-connection events** — receive text messages and card actions over persistent WebSocket
21
+ - **Async-native** — built on `httpx` and `websockets` with `asyncio`
22
+ - **Typed** — fully annotated public API, strict Pyright validation
23
+ - **Resilient** — automatic tenant-token refresh, HTTP retry with backoff, WebSocket reconnection with exponential backoff and jitter
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ uv add feishulib
29
+ ```
30
+
31
+ Requires Python >= 3.12.
32
+
33
+ ## Quick Start
34
+
35
+ ```python
36
+ from feishulib import FeishuClient, FeishuConfig
37
+
38
+ config = FeishuConfig(app_id="cli_xxx", app_secret="your_secret")
39
+ async with FeishuClient(config) as client:
40
+ receipt = await client.send_text("oc_xxx", "Hello from feishulib!")
41
+ print(receipt.message_id)
42
+ ```
43
+
44
+ ## REST API
45
+
46
+ All methods are on `FeishuClient`, used as an async context manager.
47
+
48
+ ### Sending Messages
49
+
50
+ ```python
51
+ from feishulib import FeishuClient, FeishuConfig, OutboundMessage
52
+
53
+ async with FeishuClient(FeishuConfig(app_id, secret)) as client:
54
+ # Send text
55
+ receipt = await client.send_text("oc_xxx", "Hello!")
56
+
57
+ # Send card (interactive)
58
+ card = {
59
+ "config": {"wide_screen_mode": True},
60
+ "elements": [{"tag": "markdown", "content": "**Hello**"}],
61
+ }
62
+ receipt = await client.send_card("oc_xxx", card)
63
+
64
+ # Send with custom message type
65
+ from feishulib import OutboundMessage
66
+ message = OutboundMessage(
67
+ receive_id="oc_xxx",
68
+ receive_id_type="chat_id",
69
+ msg_type="post",
70
+ content={"zh_cn": {"title": "Post", "content": [...]}},
71
+ )
72
+ receipt = await client.send_message(message)
73
+ ```
74
+
75
+ `send_message` and `send_text` accept an optional `uuid` parameter. When omitted, the client generates one automatically and retains it across transport retries for idempotency.
76
+
77
+ ### Replying to Messages
78
+
79
+ ```python
80
+ from feishulib import ReplyMessage
81
+
82
+ # Reply text
83
+ receipt = await client.reply_text("om_xxx", "Got it!")
84
+
85
+ # Reply with a card
86
+ await client.reply_message(ReplyMessage("om_xxx", "interactive", card))
87
+
88
+ # Reply in thread
89
+ await client.reply_text("om_xxx", "In thread", reply_in_thread=True)
90
+ ```
91
+
92
+ ### Updating and Deleting Messages
93
+
94
+ ```python
95
+ from feishulib import UpdateMessage
96
+
97
+ # Update message content
98
+ await client.update_message(UpdateMessage("om_xxx", "text", {"text": "Edited"}))
99
+
100
+ # Update a card
101
+ await client.update_card("om_xxx", card)
102
+
103
+ # Delete a message
104
+ await client.delete_message("om_xxx")
105
+ ```
106
+
107
+ ### Downloading Resources
108
+
109
+ ```python
110
+ content = await client.download_file("om_xxx", "file_key_xxx", resource_type="file")
111
+ # resource_type can be "file" or "image"
112
+ ```
113
+
114
+ ### Bot Identity
115
+
116
+ ```python
117
+ bot = await client.get_bot_identity()
118
+ print(bot.open_id) # e.g. "ou_xxxx"
119
+ ```
120
+
121
+ ## Long-Connection Events
122
+
123
+ feishulib supports receiving events via Feishu's long-connection protocol, using a minimal protobuf frame schema.
124
+
125
+ ### Text Message Reception
126
+
127
+ ```python
128
+ from feishulib import EventChannel, FeishuClient, FeishuConfig, FeishuWebSocket
129
+ from feishulib.events import MessageEvent
130
+
131
+ config = FeishuConfig(app_id, secret)
132
+ channel = EventChannel(config)
133
+
134
+ async with FeishuClient(config) as client:
135
+ bot = await client.get_bot_identity()
136
+
137
+ async def on_message(event: MessageEvent) -> None:
138
+ if event.sender.open_id == bot.open_id:
139
+ return # ignore messages from self
140
+ reply = reply_for_message(event) # your logic
141
+ if reply and event.chat_id:
142
+ await client.send_text(event.chat_id, reply)
143
+
144
+ channel.on("message", on_message)
145
+
146
+ async with FeishuWebSocket(config, channel) as ws:
147
+ await ws.run_forever()
148
+ ```
149
+
150
+ ### Card Action Handling
151
+
152
+ ```python
153
+ from feishulib import CardActionResponse, EventChannel, FeishuConfig, FeishuWebSocket, Toast
154
+ from feishulib.events import CardActionEvent
155
+
156
+ config = FeishuConfig(app_id, secret)
157
+ channel = EventChannel(config)
158
+
159
+ async def on_card_action(event: CardActionEvent) -> CardActionResponse:
160
+ print(f"Action from {event.operator.open_id}: {event.action_value}")
161
+ return CardActionResponse(toast=Toast(kind="success", content="Done"))
162
+
163
+ channel.on("card_action", on_card_action)
164
+
165
+ async with FeishuWebSocket(config, channel) as ws:
166
+ await ws.run_forever()
167
+ ```
168
+
169
+ ### Event Channel
170
+
171
+ The `EventChannel` dispatches incoming events to registered handlers:
172
+
173
+ | Method | Event | Handler signature | Notes |
174
+ | --- | --- | --- | --- |
175
+ | `channel.on("message", fn)` | `im.message.receive_v1` | `async (MessageEvent) -> None` | Multiple handlers allowed; run in registration order |
176
+ | `channel.on("card_action", fn)` | `card.action.trigger` | `async (CardActionEvent) -> CardActionResponse \| None` | At most one handler |
177
+
178
+ The card action handler can return a `CardActionResponse` with a toast, a card update, or both.
179
+
180
+ ### WebSocket Connection
181
+
182
+ `FeishuWebSocket` manages the long-connection lifecycle:
183
+
184
+ - Automatic endpoint discovery via `POST /callback/ws/endpoint`
185
+ - Heartbeat via ping/pong frames
186
+ - Exponential backoff reconnection with jitter on transient failures
187
+ - Configurable open/close timeouts
188
+ - Event ACK: valid events → `200`, handler failures → `503`, timeout → `500`
189
+ - Unsupported or malformed events are acknowledged with `200` and dropped gracefully
190
+
191
+ ## Configuration
192
+
193
+ ```python
194
+ from feishulib import FeishuConfig
195
+
196
+ config = FeishuConfig(
197
+ app_id="cli_xxx",
198
+ app_secret="your_secret",
199
+ # Optional overrides (defaults shown):
200
+ base_url="https://open.feishu.cn",
201
+ request_timeout_seconds=10.0,
202
+ max_retries=3,
203
+ retry_backoff_base_seconds=0.5,
204
+ retry_max_delay_seconds=15.0,
205
+ retry_jitter_ratio=0.1,
206
+ token_refresh_skew_seconds=60.0,
207
+ ws_open_timeout_seconds=15.0,
208
+ ws_close_timeout_seconds=10.0,
209
+ ws_ping_timeout_seconds=180.0,
210
+ ws_reconnect_base_seconds=1.0,
211
+ ws_reconnect_max_seconds=60.0,
212
+ ws_reconnect_jitter_ratio=0.1,
213
+ event_queue_size=100,
214
+ event_worker_count=1,
215
+ card_action_timeout_seconds=8.0,
216
+ )
217
+ ```
218
+
219
+ `app_secret` is excluded from `repr()` to prevent accidental leakage in logs.
220
+
221
+ ## Error Handling
222
+
223
+ All exceptions inherit from `FeishuError`:
224
+
225
+ | Exception | When it occurs |
226
+ | --- | --- |
227
+ | `FeishuApiError` | Feishu API returned a non-zero business code |
228
+ | `FeishuHttpStatusError` | HTTP response had a non-success status |
229
+ | `FeishuTransientError` | Retryable transport failure exhausted retry budget |
230
+ | `FeishuAuthError` | Tenant access token retrieval or validation failed |
231
+ | `FeishuProtocolError` | Remote response did not match the expected protocol |
232
+ | `FeishuWebSocketError` | WebSocket connection or frame exchange failed |
233
+ | `FeishuEventParseError` | Incoming event could not be parsed |
234
+ | `FeishuEventHandlerError` | Event handler failed or event dispatch could not proceed |
235
+
236
+ ## Security
237
+
238
+ - **Card action identity** — always use `event.operator` for authentication. Values in `event.action.value` are user-controlled and must never be trusted as an identity source.
239
+ - **Event payload** — malformed and unsupported events are acknowledged and dropped without dispatch. The client logs the parse error and event type, but never logs the raw event payload, credentials, or action values.
240
+ - **Credentials** — `app_secret` is omitted from `FeishuConfig.__repr__`. Authorization headers are redacted in logs.
241
+
242
+ ## Development
243
+
244
+ ```bash
245
+ # Clone and install
246
+ git clone <repo>
247
+ cd feishulib
248
+ uv sync
249
+
250
+ # Run quality gate
251
+ bash scripts/verify
252
+
253
+ # Individual checks
254
+ uv run ruff check .
255
+ uv run pyright
256
+ uv run pytest --cov=src/feishulib --cov-report=term-missing
257
+ ```
258
+
259
+ ## License
260
+
261
+ MIT
@@ -0,0 +1,249 @@
1
+ # feishulib
2
+
3
+ A lightweight, asynchronous, typed Python client for the Feishu IM API. No runtime dependency on `lark_oapi`.
4
+
5
+ ## Features
6
+
7
+ - **REST API** — send, reply, update, delete messages; download resources; query bot identity
8
+ - **Long-connection events** — receive text messages and card actions over persistent WebSocket
9
+ - **Async-native** — built on `httpx` and `websockets` with `asyncio`
10
+ - **Typed** — fully annotated public API, strict Pyright validation
11
+ - **Resilient** — automatic tenant-token refresh, HTTP retry with backoff, WebSocket reconnection with exponential backoff and jitter
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ uv add feishulib
17
+ ```
18
+
19
+ Requires Python >= 3.12.
20
+
21
+ ## Quick Start
22
+
23
+ ```python
24
+ from feishulib import FeishuClient, FeishuConfig
25
+
26
+ config = FeishuConfig(app_id="cli_xxx", app_secret="your_secret")
27
+ async with FeishuClient(config) as client:
28
+ receipt = await client.send_text("oc_xxx", "Hello from feishulib!")
29
+ print(receipt.message_id)
30
+ ```
31
+
32
+ ## REST API
33
+
34
+ All methods are on `FeishuClient`, used as an async context manager.
35
+
36
+ ### Sending Messages
37
+
38
+ ```python
39
+ from feishulib import FeishuClient, FeishuConfig, OutboundMessage
40
+
41
+ async with FeishuClient(FeishuConfig(app_id, secret)) as client:
42
+ # Send text
43
+ receipt = await client.send_text("oc_xxx", "Hello!")
44
+
45
+ # Send card (interactive)
46
+ card = {
47
+ "config": {"wide_screen_mode": True},
48
+ "elements": [{"tag": "markdown", "content": "**Hello**"}],
49
+ }
50
+ receipt = await client.send_card("oc_xxx", card)
51
+
52
+ # Send with custom message type
53
+ from feishulib import OutboundMessage
54
+ message = OutboundMessage(
55
+ receive_id="oc_xxx",
56
+ receive_id_type="chat_id",
57
+ msg_type="post",
58
+ content={"zh_cn": {"title": "Post", "content": [...]}},
59
+ )
60
+ receipt = await client.send_message(message)
61
+ ```
62
+
63
+ `send_message` and `send_text` accept an optional `uuid` parameter. When omitted, the client generates one automatically and retains it across transport retries for idempotency.
64
+
65
+ ### Replying to Messages
66
+
67
+ ```python
68
+ from feishulib import ReplyMessage
69
+
70
+ # Reply text
71
+ receipt = await client.reply_text("om_xxx", "Got it!")
72
+
73
+ # Reply with a card
74
+ await client.reply_message(ReplyMessage("om_xxx", "interactive", card))
75
+
76
+ # Reply in thread
77
+ await client.reply_text("om_xxx", "In thread", reply_in_thread=True)
78
+ ```
79
+
80
+ ### Updating and Deleting Messages
81
+
82
+ ```python
83
+ from feishulib import UpdateMessage
84
+
85
+ # Update message content
86
+ await client.update_message(UpdateMessage("om_xxx", "text", {"text": "Edited"}))
87
+
88
+ # Update a card
89
+ await client.update_card("om_xxx", card)
90
+
91
+ # Delete a message
92
+ await client.delete_message("om_xxx")
93
+ ```
94
+
95
+ ### Downloading Resources
96
+
97
+ ```python
98
+ content = await client.download_file("om_xxx", "file_key_xxx", resource_type="file")
99
+ # resource_type can be "file" or "image"
100
+ ```
101
+
102
+ ### Bot Identity
103
+
104
+ ```python
105
+ bot = await client.get_bot_identity()
106
+ print(bot.open_id) # e.g. "ou_xxxx"
107
+ ```
108
+
109
+ ## Long-Connection Events
110
+
111
+ feishulib supports receiving events via Feishu's long-connection protocol, using a minimal protobuf frame schema.
112
+
113
+ ### Text Message Reception
114
+
115
+ ```python
116
+ from feishulib import EventChannel, FeishuClient, FeishuConfig, FeishuWebSocket
117
+ from feishulib.events import MessageEvent
118
+
119
+ config = FeishuConfig(app_id, secret)
120
+ channel = EventChannel(config)
121
+
122
+ async with FeishuClient(config) as client:
123
+ bot = await client.get_bot_identity()
124
+
125
+ async def on_message(event: MessageEvent) -> None:
126
+ if event.sender.open_id == bot.open_id:
127
+ return # ignore messages from self
128
+ reply = reply_for_message(event) # your logic
129
+ if reply and event.chat_id:
130
+ await client.send_text(event.chat_id, reply)
131
+
132
+ channel.on("message", on_message)
133
+
134
+ async with FeishuWebSocket(config, channel) as ws:
135
+ await ws.run_forever()
136
+ ```
137
+
138
+ ### Card Action Handling
139
+
140
+ ```python
141
+ from feishulib import CardActionResponse, EventChannel, FeishuConfig, FeishuWebSocket, Toast
142
+ from feishulib.events import CardActionEvent
143
+
144
+ config = FeishuConfig(app_id, secret)
145
+ channel = EventChannel(config)
146
+
147
+ async def on_card_action(event: CardActionEvent) -> CardActionResponse:
148
+ print(f"Action from {event.operator.open_id}: {event.action_value}")
149
+ return CardActionResponse(toast=Toast(kind="success", content="Done"))
150
+
151
+ channel.on("card_action", on_card_action)
152
+
153
+ async with FeishuWebSocket(config, channel) as ws:
154
+ await ws.run_forever()
155
+ ```
156
+
157
+ ### Event Channel
158
+
159
+ The `EventChannel` dispatches incoming events to registered handlers:
160
+
161
+ | Method | Event | Handler signature | Notes |
162
+ | --- | --- | --- | --- |
163
+ | `channel.on("message", fn)` | `im.message.receive_v1` | `async (MessageEvent) -> None` | Multiple handlers allowed; run in registration order |
164
+ | `channel.on("card_action", fn)` | `card.action.trigger` | `async (CardActionEvent) -> CardActionResponse \| None` | At most one handler |
165
+
166
+ The card action handler can return a `CardActionResponse` with a toast, a card update, or both.
167
+
168
+ ### WebSocket Connection
169
+
170
+ `FeishuWebSocket` manages the long-connection lifecycle:
171
+
172
+ - Automatic endpoint discovery via `POST /callback/ws/endpoint`
173
+ - Heartbeat via ping/pong frames
174
+ - Exponential backoff reconnection with jitter on transient failures
175
+ - Configurable open/close timeouts
176
+ - Event ACK: valid events → `200`, handler failures → `503`, timeout → `500`
177
+ - Unsupported or malformed events are acknowledged with `200` and dropped gracefully
178
+
179
+ ## Configuration
180
+
181
+ ```python
182
+ from feishulib import FeishuConfig
183
+
184
+ config = FeishuConfig(
185
+ app_id="cli_xxx",
186
+ app_secret="your_secret",
187
+ # Optional overrides (defaults shown):
188
+ base_url="https://open.feishu.cn",
189
+ request_timeout_seconds=10.0,
190
+ max_retries=3,
191
+ retry_backoff_base_seconds=0.5,
192
+ retry_max_delay_seconds=15.0,
193
+ retry_jitter_ratio=0.1,
194
+ token_refresh_skew_seconds=60.0,
195
+ ws_open_timeout_seconds=15.0,
196
+ ws_close_timeout_seconds=10.0,
197
+ ws_ping_timeout_seconds=180.0,
198
+ ws_reconnect_base_seconds=1.0,
199
+ ws_reconnect_max_seconds=60.0,
200
+ ws_reconnect_jitter_ratio=0.1,
201
+ event_queue_size=100,
202
+ event_worker_count=1,
203
+ card_action_timeout_seconds=8.0,
204
+ )
205
+ ```
206
+
207
+ `app_secret` is excluded from `repr()` to prevent accidental leakage in logs.
208
+
209
+ ## Error Handling
210
+
211
+ All exceptions inherit from `FeishuError`:
212
+
213
+ | Exception | When it occurs |
214
+ | --- | --- |
215
+ | `FeishuApiError` | Feishu API returned a non-zero business code |
216
+ | `FeishuHttpStatusError` | HTTP response had a non-success status |
217
+ | `FeishuTransientError` | Retryable transport failure exhausted retry budget |
218
+ | `FeishuAuthError` | Tenant access token retrieval or validation failed |
219
+ | `FeishuProtocolError` | Remote response did not match the expected protocol |
220
+ | `FeishuWebSocketError` | WebSocket connection or frame exchange failed |
221
+ | `FeishuEventParseError` | Incoming event could not be parsed |
222
+ | `FeishuEventHandlerError` | Event handler failed or event dispatch could not proceed |
223
+
224
+ ## Security
225
+
226
+ - **Card action identity** — always use `event.operator` for authentication. Values in `event.action.value` are user-controlled and must never be trusted as an identity source.
227
+ - **Event payload** — malformed and unsupported events are acknowledged and dropped without dispatch. The client logs the parse error and event type, but never logs the raw event payload, credentials, or action values.
228
+ - **Credentials** — `app_secret` is omitted from `FeishuConfig.__repr__`. Authorization headers are redacted in logs.
229
+
230
+ ## Development
231
+
232
+ ```bash
233
+ # Clone and install
234
+ git clone <repo>
235
+ cd feishulib
236
+ uv sync
237
+
238
+ # Run quality gate
239
+ bash scripts/verify
240
+
241
+ # Individual checks
242
+ uv run ruff check .
243
+ uv run pyright
244
+ uv run pytest --cov=src/feishulib --cov-report=term-missing
245
+ ```
246
+
247
+ ## License
248
+
249
+ MIT
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "feishulib"
3
+ version = "0.1.2"
4
+ description = "Async Pythonic Feishu IM client"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "morrisx", email = "ifme.in@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "httpx>=0.27",
12
+ "protobuf>=7.35.0,<8",
13
+ "websockets>=14",
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.11.28,<0.12.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [tool.uv]
21
+ cache-dir = ".cache/uv"
22
+
23
+ [tool.uv.build-backend]
24
+ module-name = "feishulib"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "grpcio-tools>=1.82.1,<1.83",
29
+ "pyright>=1.1.390",
30
+ "pytest>=8",
31
+ "pytest-asyncio>=0.24",
32
+ "pytest-cov>=5",
33
+ "ruff>=0.6",
34
+ ]
35
+
36
+ [tool.pytest.ini_options]
37
+ cache_dir = ".cache/.pytest_cache"
38
+
39
+ [tool.ruff]
40
+ cache-dir = ".cache/.ruff_cache"
41
+
42
+ [tool.coverage.run]
43
+ data_file = ".cache/.coverage"
44
+
45
+ [tool.coverage.html]
46
+ directory = ".cache/htmlcov"
47
+
48
+ [tool.pyright]
49
+ include = ["src/feishulib"]
50
+ exclude = ["tests", "src/feishulib/proto"]
51
+ pythonVersion = "3.12"
52
+ typeCheckingMode = "strict"
53
+ reportMissingTypeStubs = "none"
@@ -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
+ ]
@@ -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