lark-bridge 0.1.0__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.
- lark_bridge-0.1.0/.github/workflows/ci.yml +20 -0
- lark_bridge-0.1.0/.github/workflows/publish.yml +19 -0
- lark_bridge-0.1.0/.gitignore +15 -0
- lark_bridge-0.1.0/LICENSE +21 -0
- lark_bridge-0.1.0/PKG-INFO +114 -0
- lark_bridge-0.1.0/README.md +88 -0
- lark_bridge-0.1.0/pyproject.toml +49 -0
- lark_bridge-0.1.0/src/lark_bridge/__init__.py +6 -0
- lark_bridge-0.1.0/src/lark_bridge/client.py +64 -0
- lark_bridge-0.1.0/src/lark_bridge/decoder.py +144 -0
- lark_bridge-0.1.0/src/lark_bridge/drive.py +91 -0
- lark_bridge-0.1.0/src/lark_bridge/listener.py +202 -0
- lark_bridge-0.1.0/src/lark_bridge/proto/__init__.py +0 -0
- lark_bridge-0.1.0/src/lark_bridge/proto/proto.proto +743 -0
- lark_bridge-0.1.0/src/lark_bridge/proto/proto_pb2.py +210 -0
- lark_bridge-0.1.0/src/lark_bridge/search.py +229 -0
- lark_bridge-0.1.0/src/lark_bridge/sender.py +106 -0
- lark_bridge-0.1.0/tests/__init__.py +0 -0
- lark_bridge-0.1.0/tests/test_client.py +24 -0
- lark_bridge-0.1.0/tests/test_decoder.py +14 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
lint-and-test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.11"
|
|
17
|
+
- run: pip install -e ".[dev]"
|
|
18
|
+
- run: ruff check src/lark_bridge/*.py tests/
|
|
19
|
+
- run: ruff format --check src/lark_bridge/*.py tests/
|
|
20
|
+
- run: pytest
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
id-token: write
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.11"
|
|
17
|
+
- run: pip install hatchling build
|
|
18
|
+
- run: python -m build
|
|
19
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan Zhu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lark-bridge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial Feishu/Lark Web SDK - WebSocket listener, message search, send, and drive operations
|
|
5
|
+
Project-URL: Homepage, https://github.com/LiangFu/lark-bridge
|
|
6
|
+
Project-URL: Repository, https://github.com/LiangFu/lark-bridge
|
|
7
|
+
Author: Ryan Zhu
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: feishu,im,lark,protobuf,websocket
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Communications :: Chat
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: httpx>=0.27.0
|
|
18
|
+
Requires-Dist: protobuf-to-dict>=0.1.0
|
|
19
|
+
Requires-Dist: protobuf>=5.0.0
|
|
20
|
+
Requires-Dist: websockets>=12.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.5.0; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# lark-bridge
|
|
28
|
+
|
|
29
|
+
Unofficial Feishu/Lark Web SDK using cookie-based authentication. Provides real-time message listening via WebSocket, message search/fetch, sending, and Drive operations.
|
|
30
|
+
|
|
31
|
+
> ⚠️ This library is reverse-engineered from Feishu's web client. It is **not** an official API and may break without notice.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install lark-bridge
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import asyncio
|
|
43
|
+
from lark_bridge import LarkBridge
|
|
44
|
+
|
|
45
|
+
bridge = LarkBridge("your_cookie_string_here")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Listen to Messages
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
async def main():
|
|
52
|
+
async for msg in bridge.listen(watch_chats=["chat_id"]):
|
|
53
|
+
print(f"[{msg['chat_id']}] {msg['from_id']}: {msg['text']}")
|
|
54
|
+
|
|
55
|
+
asyncio.run(main())
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Search History
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
result = await bridge.search_messages(
|
|
62
|
+
chat_id="7052636707732193282",
|
|
63
|
+
start_time=1716192000,
|
|
64
|
+
end_time=1716278400,
|
|
65
|
+
)
|
|
66
|
+
print(result["msg_ids"])
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Fetch Messages
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
messages = await bridge.fetch_messages(["msg_id_1", "msg_id_2"])
|
|
73
|
+
for msg in messages:
|
|
74
|
+
print(msg["text"])
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Send Message
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
await bridge.send_message(
|
|
81
|
+
chat_id="7052636707732193282",
|
|
82
|
+
text="Hello!",
|
|
83
|
+
reply_id="optional_msg_id",
|
|
84
|
+
at_user_ids=["user_id"],
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Drive Operations
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
folder = await bridge.create_folder("My Folder", parent_token="root_token")
|
|
92
|
+
result = await bridge.upload_file(folder["token"], "report.txt", b"file content")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Cookie Setup
|
|
96
|
+
|
|
97
|
+
1. Open [Feishu Web](https://www.feishu.cn/) in your browser and log in
|
|
98
|
+
2. Open DevTools (F12) → Application → Cookies
|
|
99
|
+
3. Copy the full cookie string (all key=value pairs joined by `; `)
|
|
100
|
+
4. Pass it to `LarkBridge("your_cookie_string")`
|
|
101
|
+
|
|
102
|
+
Example cookie format:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
cookie = "passport_web_did=YOUR_DID; session=YOUR_SESSION; _csrf_token=YOUR_CSRF_TOKEN"
|
|
106
|
+
|
|
107
|
+
bridge = LarkBridge(cookie)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Cookie typically stays valid as long as the WebSocket connection is maintained.
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# lark-bridge
|
|
2
|
+
|
|
3
|
+
Unofficial Feishu/Lark Web SDK using cookie-based authentication. Provides real-time message listening via WebSocket, message search/fetch, sending, and Drive operations.
|
|
4
|
+
|
|
5
|
+
> ⚠️ This library is reverse-engineered from Feishu's web client. It is **not** an official API and may break without notice.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install lark-bridge
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import asyncio
|
|
17
|
+
from lark_bridge import LarkBridge
|
|
18
|
+
|
|
19
|
+
bridge = LarkBridge("your_cookie_string_here")
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Listen to Messages
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
async def main():
|
|
26
|
+
async for msg in bridge.listen(watch_chats=["chat_id"]):
|
|
27
|
+
print(f"[{msg['chat_id']}] {msg['from_id']}: {msg['text']}")
|
|
28
|
+
|
|
29
|
+
asyncio.run(main())
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Search History
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
result = await bridge.search_messages(
|
|
36
|
+
chat_id="7052636707732193282",
|
|
37
|
+
start_time=1716192000,
|
|
38
|
+
end_time=1716278400,
|
|
39
|
+
)
|
|
40
|
+
print(result["msg_ids"])
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Fetch Messages
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
messages = await bridge.fetch_messages(["msg_id_1", "msg_id_2"])
|
|
47
|
+
for msg in messages:
|
|
48
|
+
print(msg["text"])
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Send Message
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
await bridge.send_message(
|
|
55
|
+
chat_id="7052636707732193282",
|
|
56
|
+
text="Hello!",
|
|
57
|
+
reply_id="optional_msg_id",
|
|
58
|
+
at_user_ids=["user_id"],
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Drive Operations
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
folder = await bridge.create_folder("My Folder", parent_token="root_token")
|
|
66
|
+
result = await bridge.upload_file(folder["token"], "report.txt", b"file content")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Cookie Setup
|
|
70
|
+
|
|
71
|
+
1. Open [Feishu Web](https://www.feishu.cn/) in your browser and log in
|
|
72
|
+
2. Open DevTools (F12) → Application → Cookies
|
|
73
|
+
3. Copy the full cookie string (all key=value pairs joined by `; `)
|
|
74
|
+
4. Pass it to `LarkBridge("your_cookie_string")`
|
|
75
|
+
|
|
76
|
+
Example cookie format:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
cookie = "passport_web_did=YOUR_DID; session=YOUR_SESSION; _csrf_token=YOUR_CSRF_TOKEN"
|
|
80
|
+
|
|
81
|
+
bridge = LarkBridge(cookie)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Cookie typically stays valid as long as the WebSocket connection is maintained.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lark-bridge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Unofficial Feishu/Lark Web SDK - WebSocket listener, message search, send, and drive operations"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Ryan Zhu" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["feishu", "lark", "websocket", "protobuf", "im"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Communications :: Chat",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"httpx>=0.27.0",
|
|
25
|
+
"websockets>=12.0",
|
|
26
|
+
"protobuf>=5.0.0",
|
|
27
|
+
"protobuf-to-dict>=0.1.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["ruff>=0.5.0", "pytest>=8.0", "pytest-asyncio>=0.23"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/LiangFu/lark-bridge"
|
|
35
|
+
Repository = "https://github.com/LiangFu/lark-bridge"
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 120
|
|
39
|
+
target-version = "py311"
|
|
40
|
+
exclude = ["**/proto/"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint.per-file-ignores]
|
|
43
|
+
"*_pb2.py" = ["ALL"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/lark_bridge"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Main LarkBridge client class."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
|
|
5
|
+
from lark_bridge import listener, search, sender, drive
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LarkBridge:
|
|
9
|
+
"""High-level Feishu/Lark client using web cookie authentication."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, cookie: str):
|
|
12
|
+
self._cookie = cookie.strip()
|
|
13
|
+
self._cookies = self._parse_cookies(self._cookie)
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def _parse_cookies(cookie_str: str) -> dict[str, str]:
|
|
17
|
+
return dict(
|
|
18
|
+
item.split("=", 1) for item in cookie_str.split("; ") if "=" in item
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
async def listen(self, watch_chats: list[str] | None = None) -> AsyncGenerator[dict, None]:
|
|
22
|
+
"""Connect to WebSocket and yield Message dicts."""
|
|
23
|
+
async for msg in listener.listen(self._cookie, self._cookies, watch_chats):
|
|
24
|
+
yield msg
|
|
25
|
+
|
|
26
|
+
async def search_messages(
|
|
27
|
+
self,
|
|
28
|
+
chat_id: str,
|
|
29
|
+
start_time: int = 0,
|
|
30
|
+
end_time: int = 0,
|
|
31
|
+
from_id: str = "",
|
|
32
|
+
mention_user_id: str = "",
|
|
33
|
+
limit: int = 15,
|
|
34
|
+
) -> dict:
|
|
35
|
+
"""Search message IDs. Returns {"msg_ids": [...], "has_more": bool}."""
|
|
36
|
+
return await search.search_msg_ids(
|
|
37
|
+
self._cookie, chat_id, start_time, end_time, from_id, mention_user_id, limit
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async def fetch_messages(self, msg_ids: list[str]) -> list[dict]:
|
|
41
|
+
"""Fetch message content by IDs."""
|
|
42
|
+
return await search.fetch_messages(self._cookie, msg_ids)
|
|
43
|
+
|
|
44
|
+
async def send_message(
|
|
45
|
+
self,
|
|
46
|
+
chat_id: str,
|
|
47
|
+
text: str,
|
|
48
|
+
reply_id: str = "",
|
|
49
|
+
at_user_ids: list[str] | None = None,
|
|
50
|
+
) -> bool:
|
|
51
|
+
"""Send a text message. Returns True on success."""
|
|
52
|
+
return await sender.send_message(self._cookie, chat_id, text, reply_id, at_user_ids)
|
|
53
|
+
|
|
54
|
+
async def create_folder(self, name: str, parent_token: str = "") -> dict | None:
|
|
55
|
+
"""Create a Drive folder. Returns {"token", "url"} or None."""
|
|
56
|
+
return await drive.create_folder(self._cookie, self._cookies, name, parent_token)
|
|
57
|
+
|
|
58
|
+
async def upload_file(
|
|
59
|
+
self, folder_token: str, file_name: str, file_content: bytes
|
|
60
|
+
) -> dict | None:
|
|
61
|
+
"""Upload a file to Drive. Returns {"file_token", "node_token"} or None."""
|
|
62
|
+
return await drive.upload_file(
|
|
63
|
+
self._cookie, self._cookies, folder_token, file_name, file_content
|
|
64
|
+
)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Text decoding for Feishu message protobuf content."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from google.protobuf.internal.decoder import _DecodeVarint
|
|
7
|
+
|
|
8
|
+
from lark_bridge.proto import proto_pb2 as pb
|
|
9
|
+
from protobuf_to_dict import protobuf_to_dict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def decode_text(msg: dict) -> str:
|
|
13
|
+
"""Extract text from a message dict (with 'type' and 'content' fields)."""
|
|
14
|
+
msg_type = msg.get("type", 0)
|
|
15
|
+
content = msg.get("content", b"")
|
|
16
|
+
if not content:
|
|
17
|
+
return ""
|
|
18
|
+
if msg_type == 4:
|
|
19
|
+
return decode_type4(content)
|
|
20
|
+
elif msg_type == 14:
|
|
21
|
+
return decode_type14(content)
|
|
22
|
+
return ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def decode_type4(content: bytes) -> str:
|
|
26
|
+
"""Decode type=4 (plain text) message."""
|
|
27
|
+
try:
|
|
28
|
+
tc = pb.TextContent()
|
|
29
|
+
tc.ParseFromString(content)
|
|
30
|
+
tc_dict = protobuf_to_dict(tc)
|
|
31
|
+
rt = tc_dict.get("richText", {})
|
|
32
|
+
elements = rt.get("elements", {}).get("dictionary", {})
|
|
33
|
+
element_ids = rt.get("elementIds", [])
|
|
34
|
+
at_ids = set(rt.get("atIds", []))
|
|
35
|
+
|
|
36
|
+
def _extract(eid: str) -> str:
|
|
37
|
+
el = elements.get(eid, {})
|
|
38
|
+
prop = el.get("property", b"")
|
|
39
|
+
parts: list[str] = []
|
|
40
|
+
if prop:
|
|
41
|
+
tp = pb.TextProperty()
|
|
42
|
+
tp.ParseFromString(prop)
|
|
43
|
+
if eid in at_ids:
|
|
44
|
+
parts.append(f"@({tp.content})")
|
|
45
|
+
elif tp.content:
|
|
46
|
+
parts.append(tp.content)
|
|
47
|
+
for cid in el.get("childIds", []):
|
|
48
|
+
parts.append(_extract(cid))
|
|
49
|
+
return "".join(parts)
|
|
50
|
+
|
|
51
|
+
return "".join(_extract(eid) for eid in element_ids)
|
|
52
|
+
except Exception:
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def decode_type14(content: bytes) -> str:
|
|
57
|
+
"""Decode type=14 (post/card) message."""
|
|
58
|
+
try:
|
|
59
|
+
title = ""
|
|
60
|
+
json_body = ""
|
|
61
|
+
pos = 0
|
|
62
|
+
while pos < len(content):
|
|
63
|
+
tag, new_pos = _DecodeVarint(content, pos)
|
|
64
|
+
fn = tag >> 3
|
|
65
|
+
wt = tag & 0x7
|
|
66
|
+
if wt == 0:
|
|
67
|
+
_, pos = _DecodeVarint(content, new_pos)
|
|
68
|
+
elif wt == 2:
|
|
69
|
+
length, pos = _DecodeVarint(content, new_pos)
|
|
70
|
+
val = content[pos : pos + length]
|
|
71
|
+
if fn == 20:
|
|
72
|
+
json_body = val.decode("utf-8", errors="replace")
|
|
73
|
+
elif fn == 8:
|
|
74
|
+
try:
|
|
75
|
+
tp = 0
|
|
76
|
+
_, tp = _DecodeVarint(val, tp)
|
|
77
|
+
t_len, tp = _DecodeVarint(val, tp)
|
|
78
|
+
title = val[tp : tp + t_len].decode("utf-8", errors="replace")
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
pos += length
|
|
82
|
+
else:
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
if json_body:
|
|
86
|
+
body = json.loads(json_body)
|
|
87
|
+
parts: list[str] = []
|
|
88
|
+
for el in body.get("body", {}).get("elements", []):
|
|
89
|
+
tag_name = el.get("tag", "")
|
|
90
|
+
prop = el.get("property", {})
|
|
91
|
+
if tag_name == "markdown":
|
|
92
|
+
for sub in prop.get("elements", []):
|
|
93
|
+
sub_tag = sub.get("tag", "")
|
|
94
|
+
sub_prop = sub.get("property", {})
|
|
95
|
+
if sub_tag == "plain_text":
|
|
96
|
+
parts.append(sub_prop.get("content", ""))
|
|
97
|
+
elif sub_tag == "br":
|
|
98
|
+
parts.append("\n")
|
|
99
|
+
elif sub_tag == "a":
|
|
100
|
+
parts.append(sub_prop.get("href", sub_prop.get("content", "")))
|
|
101
|
+
elif sub_tag == "at":
|
|
102
|
+
parts.append(f"@({sub_prop.get('userID', '')})")
|
|
103
|
+
else:
|
|
104
|
+
parts.append(sub_prop.get("content", ""))
|
|
105
|
+
elif tag_name == "plain_text":
|
|
106
|
+
parts.append(prop.get("content", ""))
|
|
107
|
+
elif tag_name == "br":
|
|
108
|
+
parts.append("\n")
|
|
109
|
+
text = "".join(parts)
|
|
110
|
+
if title:
|
|
111
|
+
text = f"{title}\n{text}"
|
|
112
|
+
else:
|
|
113
|
+
# Fallback: protobuf tree traversal
|
|
114
|
+
pos = 0
|
|
115
|
+
_, pos = _DecodeVarint(content, pos)
|
|
116
|
+
_, pos = _DecodeVarint(content, pos)
|
|
117
|
+
_, pos = _DecodeVarint(content, pos)
|
|
118
|
+
length, pos = _DecodeVarint(content, pos)
|
|
119
|
+
rt = pb.RichText()
|
|
120
|
+
rt.ParseFromString(content[pos : pos + length])
|
|
121
|
+
rt_dict = protobuf_to_dict(rt)
|
|
122
|
+
elems = rt_dict.get("elements", {}).get("dictionary", {})
|
|
123
|
+
eids = rt_dict.get("elementIds", [])
|
|
124
|
+
|
|
125
|
+
def _ext(eid: str) -> str:
|
|
126
|
+
el = elems.get(eid, {})
|
|
127
|
+
prop = el.get("property", b"")
|
|
128
|
+
p: list[str] = []
|
|
129
|
+
if prop:
|
|
130
|
+
tp = pb.TextProperty()
|
|
131
|
+
tp.ParseFromString(prop)
|
|
132
|
+
if tp.content:
|
|
133
|
+
p.append(tp.content)
|
|
134
|
+
for cid in el.get("childIds", []):
|
|
135
|
+
p.append(_ext(cid))
|
|
136
|
+
return "".join(p)
|
|
137
|
+
|
|
138
|
+
text = "".join(_ext(eid) for eid in eids)
|
|
139
|
+
if title:
|
|
140
|
+
text = f"{title}\n{text}"
|
|
141
|
+
|
|
142
|
+
return re.sub(r"\x1b\[[0-9;]*m", "", text)
|
|
143
|
+
except Exception:
|
|
144
|
+
return ""
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Feishu Drive operations: create folder, upload file."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import zlib
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def create_folder(
|
|
12
|
+
cookie: str, cookies: dict[str, str], name: str, parent_token: str = ""
|
|
13
|
+
) -> dict | None:
|
|
14
|
+
"""Create a folder in Feishu Drive.
|
|
15
|
+
|
|
16
|
+
Returns {"token": "...", "url": "..."} or None.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
headers = {
|
|
20
|
+
"Cookie": cookie,
|
|
21
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
22
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
23
|
+
"x-csrftoken": cookies.get("_csrf_token", ""),
|
|
24
|
+
"referer": "https://ciloa.feishu.cn/",
|
|
25
|
+
"origin": "https://ciloa.feishu.cn",
|
|
26
|
+
}
|
|
27
|
+
data = f"parent_token={parent_token}&name={name}&desc=&source=0"
|
|
28
|
+
async with httpx.AsyncClient(headers=headers, verify=False, timeout=15) as client:
|
|
29
|
+
resp = await client.post(
|
|
30
|
+
"https://ciloa.feishu.cn/space/api/explorer/v2/create/folder/",
|
|
31
|
+
content=data,
|
|
32
|
+
)
|
|
33
|
+
result = resp.json()
|
|
34
|
+
if result.get("code") != 0:
|
|
35
|
+
logger.error(f"Create folder failed: {result.get('msg')}")
|
|
36
|
+
return None
|
|
37
|
+
nodes = result.get("data", {}).get("entities", {}).get("nodes", {})
|
|
38
|
+
node = next(iter(nodes.values()), {})
|
|
39
|
+
return {"token": node.get("token", ""), "url": node.get("url", "")}
|
|
40
|
+
except Exception as e:
|
|
41
|
+
logger.error(f"Create folder error: {e}")
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def upload_file(
|
|
46
|
+
cookie: str,
|
|
47
|
+
cookies: dict[str, str],
|
|
48
|
+
folder_token: str,
|
|
49
|
+
file_name: str,
|
|
50
|
+
file_content: bytes,
|
|
51
|
+
) -> dict | None:
|
|
52
|
+
"""Upload a file to Feishu Drive.
|
|
53
|
+
|
|
54
|
+
Returns {"file_token": "...", "node_token": "..."} or None.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
checksum = zlib.adler32(file_content) & 0xFFFFFFFF
|
|
58
|
+
params = {
|
|
59
|
+
"name": file_name,
|
|
60
|
+
"size": len(file_content),
|
|
61
|
+
"checksum": str(checksum),
|
|
62
|
+
"mount_node_token": folder_token,
|
|
63
|
+
"mount_point": "explorer",
|
|
64
|
+
"push_open_history_record": "1",
|
|
65
|
+
"size_checker": "true",
|
|
66
|
+
}
|
|
67
|
+
headers = {
|
|
68
|
+
"Cookie": cookie,
|
|
69
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
70
|
+
"x-csrftoken": cookies.get("_csrf_token", ""),
|
|
71
|
+
"referer": "https://ciloa.feishu.cn/",
|
|
72
|
+
"origin": "https://ciloa.feishu.cn",
|
|
73
|
+
}
|
|
74
|
+
async with httpx.AsyncClient(headers=headers, verify=False, timeout=60) as client:
|
|
75
|
+
resp = await client.post(
|
|
76
|
+
"https://internal-api-drive-stream.feishu.cn/space/api/box/stream/upload/all/",
|
|
77
|
+
params=params,
|
|
78
|
+
files={"file": (file_name, file_content)},
|
|
79
|
+
)
|
|
80
|
+
result = resp.json()
|
|
81
|
+
if result.get("code") != 0:
|
|
82
|
+
logger.error(f"Upload file failed: {result.get('message')}")
|
|
83
|
+
return None
|
|
84
|
+
data = result.get("data", {})
|
|
85
|
+
return {
|
|
86
|
+
"file_token": data.get("file_token", ""),
|
|
87
|
+
"node_token": data.get("extra", {}).get("node_token", ""),
|
|
88
|
+
}
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.error(f"Upload file error: {e}")
|
|
91
|
+
return None
|