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.
- feishulib-0.1.2/PKG-INFO +261 -0
- feishulib-0.1.2/README.md +249 -0
- feishulib-0.1.2/pyproject.toml +53 -0
- feishulib-0.1.2/src/feishulib/__init__.py +61 -0
- feishulib-0.1.2/src/feishulib/auth.py +68 -0
- feishulib-0.1.2/src/feishulib/channel.py +107 -0
- feishulib-0.1.2/src/feishulib/client.py +191 -0
- feishulib-0.1.2/src/feishulib/config.py +55 -0
- feishulib-0.1.2/src/feishulib/events.py +146 -0
- feishulib-0.1.2/src/feishulib/exceptions.py +60 -0
- feishulib-0.1.2/src/feishulib/http.py +199 -0
- feishulib-0.1.2/src/feishulib/models.py +123 -0
- feishulib-0.1.2/src/feishulib/proto/__init__.py +0 -0
- feishulib-0.1.2/src/feishulib/proto/frame.proto +20 -0
- feishulib-0.1.2/src/feishulib/proto/frame_pb2.py +38 -0
- feishulib-0.1.2/src/feishulib/protocol.py +111 -0
- feishulib-0.1.2/src/feishulib/py.typed +0 -0
- feishulib-0.1.2/src/feishulib/websocket.py +248 -0
feishulib-0.1.2/PKG-INFO
ADDED
|
@@ -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
|