pywecomarchive 0.1.0__py3-none-win_amd64.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.
- pywecomarchive/__init__.py +48 -0
- pywecomarchive/_adapter.py +206 -0
- pywecomarchive/_buffers.py +86 -0
- pywecomarchive/_native.py +118 -0
- pywecomarchive/_validation.py +127 -0
- pywecomarchive/client.py +103 -0
- pywecomarchive/errors.py +148 -0
- pywecomarchive/lib/windows/WeWorkFinanceSdk.dll +0 -0
- pywecomarchive/lib/windows/libcrypto-3-x64.dll +0 -0
- pywecomarchive/lib/windows/libcurl-x64.dll +0 -0
- pywecomarchive/lib/windows/libssl-3-x64.dll +0 -0
- pywecomarchive/models.py +648 -0
- pywecomarchive/py.typed +0 -0
- pywecomarchive-0.1.0.dist-info/METADATA +122 -0
- pywecomarchive-0.1.0.dist-info/RECORD +19 -0
- pywecomarchive-0.1.0.dist-info/WHEEL +5 -0
- pywecomarchive-0.1.0.dist-info/licenses/LICENSE +25 -0
- pywecomarchive-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +16 -0
- pywecomarchive-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""企业微信会话存档同步 SDK,公开输入输出均为 Python 数据类型。"""
|
|
2
|
+
|
|
3
|
+
from .client import WecomArchiveSdk
|
|
4
|
+
from .errors import (
|
|
5
|
+
SdkCallError,
|
|
6
|
+
SdkClosedError,
|
|
7
|
+
SdkDataExpiredError,
|
|
8
|
+
SdkDataParseError,
|
|
9
|
+
SdkDecryptError,
|
|
10
|
+
SdkDecryptMsgError,
|
|
11
|
+
SdkDeprecated10004Error,
|
|
12
|
+
SdkDeprecated10007Error,
|
|
13
|
+
SdkError,
|
|
14
|
+
SdkFileIdError,
|
|
15
|
+
SdkInvalidIpError,
|
|
16
|
+
SdkLoadError,
|
|
17
|
+
SdkNetworkError,
|
|
18
|
+
SdkRequestParameterError,
|
|
19
|
+
SdkResponseError,
|
|
20
|
+
SdkSslCertificateError,
|
|
21
|
+
SdkSystemCallError,
|
|
22
|
+
)
|
|
23
|
+
from .models import ChatDataResponse, EncryptedChatData, MediaData, Response
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Response",
|
|
27
|
+
"ChatDataResponse",
|
|
28
|
+
"EncryptedChatData",
|
|
29
|
+
"SdkRequestParameterError",
|
|
30
|
+
"SdkNetworkError",
|
|
31
|
+
"SdkDataParseError",
|
|
32
|
+
"SdkSystemCallError",
|
|
33
|
+
"SdkDeprecated10004Error",
|
|
34
|
+
"SdkFileIdError",
|
|
35
|
+
"SdkDecryptError",
|
|
36
|
+
"SdkDeprecated10007Error",
|
|
37
|
+
"SdkDecryptMsgError",
|
|
38
|
+
"SdkInvalidIpError",
|
|
39
|
+
"SdkDataExpiredError",
|
|
40
|
+
"SdkSslCertificateError",
|
|
41
|
+
"WecomArchiveSdk",
|
|
42
|
+
"MediaData",
|
|
43
|
+
"SdkCallError",
|
|
44
|
+
"SdkClosedError",
|
|
45
|
+
"SdkError",
|
|
46
|
+
"SdkLoadError",
|
|
47
|
+
"SdkResponseError",
|
|
48
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""接口适配:校验参数、转换编码、处理错误并管理原生资源。"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import TypeAdapter, ValidationError
|
|
8
|
+
|
|
9
|
+
from ._buffers import NativeBuffers
|
|
10
|
+
from ._native import NativeBinding
|
|
11
|
+
from ._validation import (
|
|
12
|
+
CString,
|
|
13
|
+
Limit,
|
|
14
|
+
Pointer,
|
|
15
|
+
RequiredString,
|
|
16
|
+
SdkConfig,
|
|
17
|
+
Sequence,
|
|
18
|
+
checked,
|
|
19
|
+
)
|
|
20
|
+
from .errors import (
|
|
21
|
+
SDK_ERROR_TYPES,
|
|
22
|
+
SdkCallError,
|
|
23
|
+
SdkClosedError,
|
|
24
|
+
SdkError,
|
|
25
|
+
SdkResponseError,
|
|
26
|
+
)
|
|
27
|
+
from .models import ChatDataResponse, MediaData
|
|
28
|
+
|
|
29
|
+
_POINTER_ADAPTER = TypeAdapter(Pointer)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SdkAdapter:
|
|
33
|
+
"""返回 Python 数据,并在内部管理 SDK 句柄和原生缓冲区。"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
corpid: str,
|
|
38
|
+
secret: str,
|
|
39
|
+
*,
|
|
40
|
+
proxy: str = "",
|
|
41
|
+
passwd: str = "",
|
|
42
|
+
timeout: int = 5,
|
|
43
|
+
library_path: str | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
config = SdkConfig(
|
|
46
|
+
corpid=corpid,
|
|
47
|
+
secret=secret,
|
|
48
|
+
proxy=proxy,
|
|
49
|
+
passwd=passwd,
|
|
50
|
+
timeout=timeout,
|
|
51
|
+
library_path=library_path,
|
|
52
|
+
)
|
|
53
|
+
self._config = config
|
|
54
|
+
self._binding = NativeBinding(config.library_path)
|
|
55
|
+
self._lib = self._binding.lib
|
|
56
|
+
self._utils = NativeBuffers(self._binding)
|
|
57
|
+
self._closed = False
|
|
58
|
+
self._handle = None
|
|
59
|
+
try:
|
|
60
|
+
self._handle = _POINTER_ADAPTER.validate_python(
|
|
61
|
+
self._lib.NewSdk(), strict=True
|
|
62
|
+
)
|
|
63
|
+
self._check(
|
|
64
|
+
"Init", self._lib.Init(self._handle, config.corpid, config.secret)
|
|
65
|
+
)
|
|
66
|
+
except ValidationError:
|
|
67
|
+
self.close()
|
|
68
|
+
raise SdkError("SDK 句柄分配失败") from None
|
|
69
|
+
except BaseException:
|
|
70
|
+
self.close()
|
|
71
|
+
raise
|
|
72
|
+
|
|
73
|
+
def _ensure_open(self) -> None:
|
|
74
|
+
if self._closed:
|
|
75
|
+
raise SdkClosedError("SDK 已经关闭")
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _check(operation: str, code: int | None) -> None:
|
|
79
|
+
if type(code) is not int:
|
|
80
|
+
raise SdkResponseError(f"{operation} 返回无效的业务错误码")
|
|
81
|
+
if code == 0:
|
|
82
|
+
return
|
|
83
|
+
error_type = SDK_ERROR_TYPES.get(code)
|
|
84
|
+
if error_type is not None:
|
|
85
|
+
raise error_type(operation)
|
|
86
|
+
raise SdkCallError(operation, code)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _parse_json(raw: bytes, operation: str) -> dict[str, Any]:
|
|
90
|
+
try:
|
|
91
|
+
result = json.loads(raw)
|
|
92
|
+
except (ValueError, UnicodeError):
|
|
93
|
+
raise SdkResponseError(f"{operation} 返回无效的 JSON 数据") from None
|
|
94
|
+
if not isinstance(result, dict):
|
|
95
|
+
raise SdkResponseError(f"{operation} 返回的 JSON 必须是对象")
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
@checked
|
|
99
|
+
def get_chat_data(self, seq: Sequence = 0, limit: Limit = 1000) -> ChatDataResponse:
|
|
100
|
+
"""拉取一批加密消息,校验后返回响应模型。"""
|
|
101
|
+
self._ensure_open()
|
|
102
|
+
buffer = self._utils.new_slice()
|
|
103
|
+
try:
|
|
104
|
+
self._check(
|
|
105
|
+
"GetChatData",
|
|
106
|
+
self._lib.GetChatData(
|
|
107
|
+
self._handle,
|
|
108
|
+
seq,
|
|
109
|
+
limit,
|
|
110
|
+
self._config.proxy,
|
|
111
|
+
self._config.passwd,
|
|
112
|
+
self._config.timeout,
|
|
113
|
+
buffer,
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
result = self._parse_json(
|
|
117
|
+
self._utils.get_content_from_slice(buffer), "GetChatData"
|
|
118
|
+
)
|
|
119
|
+
code = result.get("errcode", None)
|
|
120
|
+
self._check("GetChatData", code)
|
|
121
|
+
try:
|
|
122
|
+
return ChatDataResponse.model_validate(result)
|
|
123
|
+
except ValidationError:
|
|
124
|
+
raise SdkResponseError("GetChatData 返回数据校验失败") from None
|
|
125
|
+
finally:
|
|
126
|
+
self._utils.free_slice(buffer)
|
|
127
|
+
|
|
128
|
+
@checked
|
|
129
|
+
def decrypt_data(
|
|
130
|
+
self, encrypt_key: RequiredString, encrypt_msg: RequiredString
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
"""使用已解开的消息密钥解密,直接返回消息 JSON 对象。"""
|
|
133
|
+
self._ensure_open()
|
|
134
|
+
buffer = self._utils.new_slice()
|
|
135
|
+
try:
|
|
136
|
+
self._check(
|
|
137
|
+
"DecryptData",
|
|
138
|
+
self._lib.DecryptData(
|
|
139
|
+
encrypt_key.encode(), encrypt_msg.encode(), buffer
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
return self._parse_json(
|
|
143
|
+
self._utils.get_content_from_slice(buffer), "DecryptData"
|
|
144
|
+
)
|
|
145
|
+
finally:
|
|
146
|
+
self._utils.free_slice(buffer)
|
|
147
|
+
|
|
148
|
+
@checked
|
|
149
|
+
def get_media_data(
|
|
150
|
+
self, sdkfileid: RequiredString, indexbuf: CString = ""
|
|
151
|
+
) -> MediaData:
|
|
152
|
+
"""拉取一块媒体,返回内容、下一块索引和完成状态。"""
|
|
153
|
+
self._ensure_open()
|
|
154
|
+
buffer = self._utils.new_media_data()
|
|
155
|
+
try:
|
|
156
|
+
self._check(
|
|
157
|
+
"GetMediaData",
|
|
158
|
+
self._lib.GetMediaData(
|
|
159
|
+
self._handle,
|
|
160
|
+
indexbuf.encode(),
|
|
161
|
+
sdkfileid.encode(),
|
|
162
|
+
self._config.proxy,
|
|
163
|
+
self._config.passwd,
|
|
164
|
+
self._config.timeout,
|
|
165
|
+
buffer,
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
return MediaData(
|
|
169
|
+
data=self._utils.get_data(buffer),
|
|
170
|
+
indexbuf=self._utils.get_out_index_buf(buffer),
|
|
171
|
+
is_finish=self._utils.is_media_data_finish(buffer),
|
|
172
|
+
)
|
|
173
|
+
finally:
|
|
174
|
+
self._utils.free_media_data(buffer)
|
|
175
|
+
|
|
176
|
+
def __enter__(self) -> "SdkAdapter":
|
|
177
|
+
"""进入上下文,在退出时释放 SDK 句柄。"""
|
|
178
|
+
self._ensure_open()
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
def __exit__(
|
|
182
|
+
self,
|
|
183
|
+
exc_type: type[BaseException] | None,
|
|
184
|
+
exc_value: BaseException | None,
|
|
185
|
+
traceback: TracebackType | None,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""退出时关闭封装,保留代码块内抛出的异常。"""
|
|
188
|
+
self.close()
|
|
189
|
+
|
|
190
|
+
def close(self) -> None:
|
|
191
|
+
"""销毁 SDK 句柄并关闭库搜索目录。"""
|
|
192
|
+
if getattr(self, "_closed", False):
|
|
193
|
+
return
|
|
194
|
+
self._closed = True
|
|
195
|
+
handle = getattr(self, "_handle", None)
|
|
196
|
+
self._handle = None
|
|
197
|
+
try:
|
|
198
|
+
if handle is not None:
|
|
199
|
+
self._utils.destroy_sdk(handle)
|
|
200
|
+
finally:
|
|
201
|
+
binding = getattr(self, "_binding", None)
|
|
202
|
+
if binding is not None:
|
|
203
|
+
binding.close()
|
|
204
|
+
|
|
205
|
+
def __del__(self) -> None:
|
|
206
|
+
self.close()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""接口适配层的缓冲区管理,所有指针仅供内部使用。"""
|
|
2
|
+
|
|
3
|
+
from ._native import NativeBinding
|
|
4
|
+
from ._validation import Length, Pointer, checked
|
|
5
|
+
from .errors import SdkError, SdkResponseError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NativeBuffers:
|
|
9
|
+
"""分配、读取并释放绑定层产生的缓冲区。"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, binding: NativeBinding) -> None:
|
|
12
|
+
self._lib = binding.lib
|
|
13
|
+
|
|
14
|
+
@checked
|
|
15
|
+
def destroy_sdk(self, sdk: Pointer) -> None:
|
|
16
|
+
"""释放 NewSdk 创建的句柄,调用后不得再次使用或释放。"""
|
|
17
|
+
self._lib.DestroySdk(sdk)
|
|
18
|
+
|
|
19
|
+
@checked
|
|
20
|
+
def new_slice(self) -> Pointer:
|
|
21
|
+
buffer = self._lib.NewSlice()
|
|
22
|
+
if not buffer:
|
|
23
|
+
raise SdkError("SDK 消息缓冲区分配失败")
|
|
24
|
+
return buffer
|
|
25
|
+
|
|
26
|
+
@checked
|
|
27
|
+
def free_slice(self, buffer: Pointer) -> None:
|
|
28
|
+
self._lib.FreeSlice(buffer)
|
|
29
|
+
|
|
30
|
+
@checked
|
|
31
|
+
def get_content_from_slice(self, buffer: Pointer) -> bytes:
|
|
32
|
+
return self._read(
|
|
33
|
+
self._lib.GetContentFromSlice(buffer), self._get_slice_len(buffer)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@checked
|
|
37
|
+
def new_media_data(self) -> Pointer:
|
|
38
|
+
buffer = self._lib.NewMediaData()
|
|
39
|
+
if not buffer:
|
|
40
|
+
raise SdkError("SDK 媒体缓冲区分配失败")
|
|
41
|
+
return buffer
|
|
42
|
+
|
|
43
|
+
@checked
|
|
44
|
+
def free_media_data(self, buffer: Pointer) -> None:
|
|
45
|
+
self._lib.FreeMediaData(buffer)
|
|
46
|
+
|
|
47
|
+
@checked
|
|
48
|
+
def get_out_index_buf(self, buffer: Pointer) -> str:
|
|
49
|
+
raw = self._read(self._lib.GetOutIndexBuf(buffer), self._get_index_len(buffer))
|
|
50
|
+
try:
|
|
51
|
+
return raw.decode("utf-8")
|
|
52
|
+
except UnicodeError:
|
|
53
|
+
raise SdkResponseError("GetOutIndexBuf 返回无效的 UTF-8 数据") from None
|
|
54
|
+
|
|
55
|
+
@checked
|
|
56
|
+
def get_data(self, buffer: Pointer) -> bytes:
|
|
57
|
+
return self._read(self._lib.GetData(buffer), self._get_data_len(buffer))
|
|
58
|
+
|
|
59
|
+
@checked
|
|
60
|
+
def is_media_data_finish(self, buffer: Pointer) -> bool:
|
|
61
|
+
finish = self._lib.IsMediaDataFinish(buffer)
|
|
62
|
+
if finish not in (0, 1):
|
|
63
|
+
raise SdkResponseError("IsMediaDataFinish 返回无效的完成标志")
|
|
64
|
+
return bool(finish)
|
|
65
|
+
|
|
66
|
+
@checked
|
|
67
|
+
def _get_slice_len(self, buffer: Pointer) -> Length:
|
|
68
|
+
return self._lib.GetSliceLen(buffer)
|
|
69
|
+
|
|
70
|
+
@checked
|
|
71
|
+
def _get_index_len(self, buffer: Pointer) -> Length:
|
|
72
|
+
return self._lib.GetIndexLen(buffer)
|
|
73
|
+
|
|
74
|
+
@checked
|
|
75
|
+
def _get_data_len(self, buffer: Pointer) -> Length:
|
|
76
|
+
return self._lib.GetDataLen(buffer)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
@checked
|
|
80
|
+
def _read(pointer: Pointer | None, length: Length) -> bytes:
|
|
81
|
+
if length < 0 or (length and not pointer):
|
|
82
|
+
raise SdkResponseError("SDK 返回无效的缓冲区")
|
|
83
|
+
if not length:
|
|
84
|
+
return b""
|
|
85
|
+
assert pointer is not None
|
|
86
|
+
return NativeBinding.read(pointer, length)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""底层绑定:加载动态库并声明官方 C ABI,不处理业务数据。"""
|
|
2
|
+
|
|
3
|
+
import ctypes as ct
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .errors import SdkLoadError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def default_library() -> Path:
|
|
12
|
+
root = Path(__file__).resolve().parent / "lib"
|
|
13
|
+
machine = platform.machine().lower()
|
|
14
|
+
if platform.system() == "Windows" and machine in {"amd64", "x86_64"}:
|
|
15
|
+
return root / "windows/WeWorkFinanceSdk.dll"
|
|
16
|
+
if platform.system() == "Linux" and machine in {
|
|
17
|
+
"amd64",
|
|
18
|
+
"x86_64",
|
|
19
|
+
"aarch64",
|
|
20
|
+
"arm64",
|
|
21
|
+
}:
|
|
22
|
+
arch = "arm64" if machine in {"aarch64", "arm64"} else "x86_64"
|
|
23
|
+
return root / f"linux/{arch}/libWeWorkFinanceSdk_C.so"
|
|
24
|
+
raise SdkLoadError("当前系统或架构不受本地 SDK 支持")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NativeBinding:
|
|
28
|
+
"""持有动态库与依赖搜索目录,供内部适配层使用。"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, library_path: str | None = None) -> None:
|
|
31
|
+
self._directory = None
|
|
32
|
+
try:
|
|
33
|
+
path = (Path(library_path) if library_path else default_library()).resolve()
|
|
34
|
+
if os.name == "nt":
|
|
35
|
+
self._directory = os.add_dll_directory(str(path.parent))
|
|
36
|
+
self.lib = ct.CDLL(str(path))
|
|
37
|
+
self._configure_functions()
|
|
38
|
+
except (OSError, AttributeError):
|
|
39
|
+
self.close()
|
|
40
|
+
raise SdkLoadError("原生 SDK 加载失败,请检查库文件、架构及依赖") from None
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
if self._directory is not None:
|
|
44
|
+
self._directory.close()
|
|
45
|
+
self._directory = None
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def read(pointer: int, length: int) -> bytes:
|
|
49
|
+
return ct.string_at(pointer, length)
|
|
50
|
+
|
|
51
|
+
def _configure_functions(self) -> None:
|
|
52
|
+
"""显式配置原生函数的参数类型和返回类型。"""
|
|
53
|
+
self.lib.NewSdk.argtypes = []
|
|
54
|
+
self.lib.NewSdk.restype = ct.c_void_p
|
|
55
|
+
|
|
56
|
+
self.lib.Init.argtypes = [ct.c_void_p, ct.c_char_p, ct.c_char_p]
|
|
57
|
+
self.lib.Init.restype = ct.c_int
|
|
58
|
+
|
|
59
|
+
self.lib.GetChatData.argtypes = [
|
|
60
|
+
ct.c_void_p,
|
|
61
|
+
ct.c_ulonglong,
|
|
62
|
+
ct.c_uint,
|
|
63
|
+
ct.c_char_p,
|
|
64
|
+
ct.c_char_p,
|
|
65
|
+
ct.c_int,
|
|
66
|
+
ct.c_void_p,
|
|
67
|
+
]
|
|
68
|
+
self.lib.GetChatData.restype = ct.c_int
|
|
69
|
+
|
|
70
|
+
self.lib.DecryptData.argtypes = [ct.c_char_p, ct.c_char_p, ct.c_void_p]
|
|
71
|
+
self.lib.DecryptData.restype = ct.c_int
|
|
72
|
+
|
|
73
|
+
self.lib.GetMediaData.argtypes = [
|
|
74
|
+
ct.c_void_p,
|
|
75
|
+
ct.c_char_p,
|
|
76
|
+
ct.c_char_p,
|
|
77
|
+
ct.c_char_p,
|
|
78
|
+
ct.c_char_p,
|
|
79
|
+
ct.c_int,
|
|
80
|
+
ct.c_void_p,
|
|
81
|
+
]
|
|
82
|
+
self.lib.GetMediaData.restype = ct.c_int
|
|
83
|
+
|
|
84
|
+
self.lib.DestroySdk.argtypes = [ct.c_void_p]
|
|
85
|
+
self.lib.DestroySdk.restype = None
|
|
86
|
+
|
|
87
|
+
self.lib.NewSlice.argtypes = []
|
|
88
|
+
self.lib.NewSlice.restype = ct.c_void_p
|
|
89
|
+
|
|
90
|
+
self.lib.FreeSlice.argtypes = [ct.c_void_p]
|
|
91
|
+
self.lib.FreeSlice.restype = None
|
|
92
|
+
|
|
93
|
+
self.lib.GetContentFromSlice.argtypes = [ct.c_void_p]
|
|
94
|
+
self.lib.GetContentFromSlice.restype = ct.c_void_p
|
|
95
|
+
|
|
96
|
+
self.lib.GetSliceLen.argtypes = [ct.c_void_p]
|
|
97
|
+
self.lib.GetSliceLen.restype = ct.c_int
|
|
98
|
+
|
|
99
|
+
self.lib.NewMediaData.argtypes = []
|
|
100
|
+
self.lib.NewMediaData.restype = ct.c_void_p
|
|
101
|
+
|
|
102
|
+
self.lib.FreeMediaData.argtypes = [ct.c_void_p]
|
|
103
|
+
self.lib.FreeMediaData.restype = None
|
|
104
|
+
|
|
105
|
+
self.lib.GetOutIndexBuf.argtypes = [ct.c_void_p]
|
|
106
|
+
self.lib.GetOutIndexBuf.restype = ct.c_void_p
|
|
107
|
+
|
|
108
|
+
self.lib.GetData.argtypes = [ct.c_void_p]
|
|
109
|
+
self.lib.GetData.restype = ct.c_void_p
|
|
110
|
+
|
|
111
|
+
self.lib.GetIndexLen.argtypes = [ct.c_void_p]
|
|
112
|
+
self.lib.GetIndexLen.restype = ct.c_int
|
|
113
|
+
|
|
114
|
+
self.lib.GetDataLen.argtypes = [ct.c_void_p]
|
|
115
|
+
self.lib.GetDataLen.restype = ct.c_int
|
|
116
|
+
|
|
117
|
+
self.lib.IsMediaDataFinish.argtypes = [ct.c_void_p]
|
|
118
|
+
self.lib.IsMediaDataFinish.restype = ct.c_int
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""SDK 内部配置、参数约束与调用校验。"""
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Annotated, ParamSpec, TypeVar, get_type_hints
|
|
7
|
+
|
|
8
|
+
from pydantic import (
|
|
9
|
+
AfterValidator,
|
|
10
|
+
BaseModel,
|
|
11
|
+
ConfigDict,
|
|
12
|
+
Field,
|
|
13
|
+
TypeAdapter,
|
|
14
|
+
ValidationError,
|
|
15
|
+
field_validator,
|
|
16
|
+
validate_call,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .errors import SdkResponseError
|
|
20
|
+
|
|
21
|
+
P = ParamSpec("P")
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_c_string(value: str) -> str:
|
|
26
|
+
if "\x00" in value:
|
|
27
|
+
raise ValueError("字符串不能包含空字符")
|
|
28
|
+
try:
|
|
29
|
+
value.encode("utf-8")
|
|
30
|
+
except UnicodeError:
|
|
31
|
+
raise ValueError("字符串不能编码为 UTF-8") from None
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
CString = Annotated[str, AfterValidator(check_c_string)]
|
|
36
|
+
RequiredString = Annotated[CString, Field(min_length=1)]
|
|
37
|
+
Pointer = Annotated[int, Field(gt=0, le=2 ** (ctypes.sizeof(ctypes.c_void_p) * 8) - 1)]
|
|
38
|
+
Sequence = Annotated[int, Field(ge=0, le=2**64 - 1)]
|
|
39
|
+
Limit = Annotated[int, Field(ge=1, le=1000)]
|
|
40
|
+
Length = Annotated[int, Field(ge=0, le=2**31 - 1)]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SdkConfig(BaseModel):
|
|
44
|
+
"""SDK 初始化配置,在加载原生库前严格校验。"""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(
|
|
47
|
+
strict=True, extra="forbid", frozen=True, hide_input_in_errors=True
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
corpid: bytes = Field(min_length=1, strict=False)
|
|
51
|
+
secret: bytes = Field(min_length=1, strict=False, repr=False)
|
|
52
|
+
proxy: bytes = Field(default=b"", strict=False)
|
|
53
|
+
passwd: bytes = Field(default=b"", strict=False, repr=False)
|
|
54
|
+
timeout: int = Field(default=5, ge=1, le=2**31 - 1)
|
|
55
|
+
library_path: str | None = Field(default=None, min_length=1)
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
corpid: str | bytes,
|
|
61
|
+
secret: str | bytes,
|
|
62
|
+
proxy: str | bytes = b"",
|
|
63
|
+
passwd: str | bytes = b"",
|
|
64
|
+
timeout: int = 5,
|
|
65
|
+
library_path: str | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
super().__init__(
|
|
68
|
+
corpid=corpid,
|
|
69
|
+
secret=secret,
|
|
70
|
+
proxy=proxy,
|
|
71
|
+
passwd=passwd,
|
|
72
|
+
timeout=timeout,
|
|
73
|
+
library_path=library_path,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@field_validator("corpid", "secret", "proxy", "passwd", mode="before")
|
|
77
|
+
@classmethod
|
|
78
|
+
def validate_string_input(cls, value: object) -> str | bytes:
|
|
79
|
+
"""仅接受字符串或字节串,由 Pydantic 完成编码转换。"""
|
|
80
|
+
if not isinstance(value, (str, bytes)):
|
|
81
|
+
raise ValueError("参数必须是字符串或字节串")
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
@field_validator("corpid", "secret", "proxy", "passwd")
|
|
85
|
+
@classmethod
|
|
86
|
+
def validate_c_bytes(cls, value: bytes) -> bytes:
|
|
87
|
+
"""避免 C 字符串被空字符截断,并确保内容为 UTF-8。"""
|
|
88
|
+
if b"\x00" in value:
|
|
89
|
+
raise ValueError("字符串不能包含空字符")
|
|
90
|
+
try:
|
|
91
|
+
value.decode("utf-8")
|
|
92
|
+
except UnicodeError:
|
|
93
|
+
raise ValueError("字节串必须是有效的 UTF-8") from None
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
@field_validator("library_path")
|
|
97
|
+
@classmethod
|
|
98
|
+
def validate_library_path(cls, value: str | None) -> str | None:
|
|
99
|
+
"""库路径保留字符串类型,供 pathlib 使用。"""
|
|
100
|
+
if value is None:
|
|
101
|
+
return None
|
|
102
|
+
return check_c_string(value)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def checked(function: Callable[P, T]) -> Callable[P, T]:
|
|
106
|
+
"""严格校验方法输入输出,参数错误直接抛出 Pydantic 验证异常。"""
|
|
107
|
+
config = ConfigDict(
|
|
108
|
+
strict=True, arbitrary_types_allowed=True, hide_input_in_errors=True
|
|
109
|
+
)
|
|
110
|
+
validated = validate_call(config=config)(function)
|
|
111
|
+
return_type = get_type_hints(function, include_extras=True)["return"]
|
|
112
|
+
output = TypeAdapter(
|
|
113
|
+
return_type,
|
|
114
|
+
config=None
|
|
115
|
+
if isinstance(return_type, type) and issubclass(return_type, BaseModel)
|
|
116
|
+
else config,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@wraps(function)
|
|
120
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
121
|
+
result = validated(*args, **kwargs)
|
|
122
|
+
try:
|
|
123
|
+
return output.validate_python(result, strict=True)
|
|
124
|
+
except ValidationError:
|
|
125
|
+
raise SdkResponseError(f"{function.__name__} 返回数据校验失败") from None
|
|
126
|
+
|
|
127
|
+
return wrapper
|
pywecomarchive/client.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""业务 API:调用方只需传入和接收 Python 数据。"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import TypeVar, overload
|
|
6
|
+
|
|
7
|
+
from pydantic import TypeAdapter, ValidationError
|
|
8
|
+
|
|
9
|
+
from ._adapter import SdkAdapter
|
|
10
|
+
from .errors import SdkResponseError
|
|
11
|
+
from .models import ArchiveMessage, ChatDataResponse, MediaData, message_adapter
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WecomArchiveSdk:
|
|
17
|
+
"""同步会话存档客户端;支持显式关闭与上下文管理。"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
corpid: str,
|
|
22
|
+
secret: str,
|
|
23
|
+
*,
|
|
24
|
+
proxy: str = "",
|
|
25
|
+
passwd: str = "",
|
|
26
|
+
timeout: int = 5,
|
|
27
|
+
library_path: str | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._adapter = SdkAdapter(
|
|
30
|
+
corpid,
|
|
31
|
+
secret,
|
|
32
|
+
proxy=proxy,
|
|
33
|
+
passwd=passwd,
|
|
34
|
+
timeout=timeout,
|
|
35
|
+
library_path=library_path,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def get_chat_data(self, seq: int = 0, limit: int = 1000) -> ChatDataResponse:
|
|
39
|
+
"""拉取一批加密消息,返回支持属性访问的响应模型。"""
|
|
40
|
+
return self._adapter.get_chat_data(seq, limit)
|
|
41
|
+
|
|
42
|
+
@overload
|
|
43
|
+
def decrypt_data(self, encrypt_key: str, encrypt_msg: str) -> ArchiveMessage: ...
|
|
44
|
+
|
|
45
|
+
@overload
|
|
46
|
+
def decrypt_data(
|
|
47
|
+
self, encrypt_key: str, encrypt_msg: str, *, adapter: TypeAdapter[T]
|
|
48
|
+
) -> T: ...
|
|
49
|
+
|
|
50
|
+
def decrypt_data(
|
|
51
|
+
self,
|
|
52
|
+
encrypt_key: str,
|
|
53
|
+
encrypt_msg: str,
|
|
54
|
+
*,
|
|
55
|
+
adapter: TypeAdapter[T] | None = None,
|
|
56
|
+
) -> ArchiveMessage | T:
|
|
57
|
+
"""解密并校验消息;默认使用内置模型,也可传入自定义 TypeAdapter。"""
|
|
58
|
+
value = self._adapter.decrypt_data(encrypt_key, encrypt_msg)
|
|
59
|
+
try:
|
|
60
|
+
if adapter is None:
|
|
61
|
+
return message_adapter.validate_python(value, strict=True)
|
|
62
|
+
return adapter.validate_python(value, strict=True)
|
|
63
|
+
except ValidationError:
|
|
64
|
+
raise SdkResponseError("解密后的消息格式校验失败") from None
|
|
65
|
+
|
|
66
|
+
def get_media_data(self, sdkfileid: str, indexbuf: str = "") -> MediaData:
|
|
67
|
+
"""拉取媒体分块,用返回的 indexbuf 继续获取后续内容。"""
|
|
68
|
+
return self._adapter.get_media_data(sdkfileid, indexbuf)
|
|
69
|
+
|
|
70
|
+
def iter_media_data(
|
|
71
|
+
self, sdkfileid: str, indexbuf: str = ""
|
|
72
|
+
) -> Iterator[MediaData]:
|
|
73
|
+
"""从指定索引逐块下载,首次迭代时校验参数并发起请求。
|
|
74
|
+
|
|
75
|
+
每次返回完整的 MediaData,包含可用于断点续传的下一块索引。
|
|
76
|
+
调用失败直接抛出异常;停止迭代不会关闭客户端。
|
|
77
|
+
"""
|
|
78
|
+
while True:
|
|
79
|
+
chunk = self.get_media_data(sdkfileid, indexbuf)
|
|
80
|
+
if not chunk.is_finish and (
|
|
81
|
+
not chunk.indexbuf or chunk.indexbuf == indexbuf
|
|
82
|
+
):
|
|
83
|
+
raise SdkResponseError("GetMediaData 未完成但续传索引为空或未前进")
|
|
84
|
+
yield chunk
|
|
85
|
+
if chunk.is_finish:
|
|
86
|
+
return
|
|
87
|
+
indexbuf = chunk.indexbuf
|
|
88
|
+
|
|
89
|
+
def close(self) -> None:
|
|
90
|
+
"""释放原生资源,可重复调用。"""
|
|
91
|
+
self._adapter.close()
|
|
92
|
+
|
|
93
|
+
def __enter__(self) -> "WecomArchiveSdk":
|
|
94
|
+
self._adapter.__enter__()
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def __exit__(
|
|
98
|
+
self,
|
|
99
|
+
exc_type: type[BaseException] | None,
|
|
100
|
+
exc_value: BaseException | None,
|
|
101
|
+
traceback: TracebackType | None,
|
|
102
|
+
) -> None:
|
|
103
|
+
self.close()
|