perk-pushplus-sdk 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- perk_pushplus/__init__.py +155 -0
- perk_pushplus/access_key.py +77 -0
- perk_pushplus/api/__init__.py +33 -0
- perk_pushplus/api/access_key.py +44 -0
- perk_pushplus/api/base.py +210 -0
- perk_pushplus/api/channel.py +47 -0
- perk_pushplus/api/clawbot.py +49 -0
- perk_pushplus/api/friend.py +58 -0
- perk_pushplus/api/message.py +151 -0
- perk_pushplus/api/message_token.py +52 -0
- perk_pushplus/api/open_message.py +51 -0
- perk_pushplus/api/pre.py +40 -0
- perk_pushplus/api/setting.py +90 -0
- perk_pushplus/api/topic.py +89 -0
- perk_pushplus/api/topic_user.py +35 -0
- perk_pushplus/api/user.py +34 -0
- perk_pushplus/api/webhook.py +35 -0
- perk_pushplus/callback_parser.py +51 -0
- perk_pushplus/client.py +169 -0
- perk_pushplus/config.py +85 -0
- perk_pushplus/enums.py +214 -0
- perk_pushplus/exceptions.py +61 -0
- perk_pushplus/http.py +158 -0
- perk_pushplus/models/__init__.py +90 -0
- perk_pushplus/models/base.py +246 -0
- perk_pushplus/models/callback.py +76 -0
- perk_pushplus/models/open_access.py +18 -0
- perk_pushplus/models/open_channel.py +57 -0
- perk_pushplus/models/open_clawbot.py +34 -0
- perk_pushplus/models/open_friend.py +31 -0
- perk_pushplus/models/open_message.py +36 -0
- perk_pushplus/models/open_pre.py +51 -0
- perk_pushplus/models/open_setting.py +46 -0
- perk_pushplus/models/open_token.py +49 -0
- perk_pushplus/models/open_topic.py +166 -0
- perk_pushplus/models/open_user.py +44 -0
- perk_pushplus/models/open_webhook.py +43 -0
- perk_pushplus/models/send.py +255 -0
- perk_pushplus/py.typed +0 -0
- perk_pushplus/rate_limit.py +124 -0
- perk_pushplus_sdk-1.0.0.dist-info/METADATA +233 -0
- perk_pushplus_sdk-1.0.0.dist-info/RECORD +44 -0
- perk_pushplus_sdk-1.0.0.dist-info/WHEEL +5 -0
- perk_pushplus_sdk-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""perk-pushplus-python-sdk: PushPlus 推送加官方接口的 Python SDK。
|
|
2
|
+
|
|
3
|
+
提供消息接口(``/send``、``/batchSend``)以及全部开放接口的封装,
|
|
4
|
+
内置 AccessKey 自动管理与本地限流守卫。
|
|
5
|
+
|
|
6
|
+
参考文档:
|
|
7
|
+
- 消息接口:https://www.pushplus.plus/doc/guide/api.html
|
|
8
|
+
- 开放接口:https://www.pushplus.plus/doc/guide/openApi.html
|
|
9
|
+
|
|
10
|
+
快速使用::
|
|
11
|
+
|
|
12
|
+
from perk_pushplus import PushPlusClient
|
|
13
|
+
|
|
14
|
+
client = PushPlusClient.builder() \
|
|
15
|
+
.token("your_user_token") \
|
|
16
|
+
.secret_key("your_secret_key") \
|
|
17
|
+
.build()
|
|
18
|
+
|
|
19
|
+
short_code = client.send_simple("标题", "内容")
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from . import callback_parser
|
|
24
|
+
from .access_key import AccessKeyManager
|
|
25
|
+
from .client import PushPlusClient, PushPlusClientBuilder
|
|
26
|
+
from .config import DEFAULT_BASE_URL, PushPlusConfig
|
|
27
|
+
from .enums import (
|
|
28
|
+
CallbackEvent,
|
|
29
|
+
Channel,
|
|
30
|
+
ErrorCode,
|
|
31
|
+
SendStatus,
|
|
32
|
+
Template,
|
|
33
|
+
WebhookType,
|
|
34
|
+
)
|
|
35
|
+
from .exceptions import PushPlusError, PushPlusException
|
|
36
|
+
from .http import HttpRequester, HttpResponse, RequestsHttpRequester
|
|
37
|
+
from .models import (
|
|
38
|
+
AccessKeyResult,
|
|
39
|
+
ApiResponse,
|
|
40
|
+
BatchSendRequest,
|
|
41
|
+
BatchSendResult,
|
|
42
|
+
CallbackFriendInfo,
|
|
43
|
+
CallbackPayload,
|
|
44
|
+
ClawBotInfo,
|
|
45
|
+
ClawBotMessage,
|
|
46
|
+
ClawBotQrCode,
|
|
47
|
+
CpItem,
|
|
48
|
+
FriendItem,
|
|
49
|
+
FriendQrCode,
|
|
50
|
+
MailDetail,
|
|
51
|
+
MailItem,
|
|
52
|
+
MessageCompleteInfo,
|
|
53
|
+
MessageItem,
|
|
54
|
+
MessageTokenAddRequest,
|
|
55
|
+
MessageTokenEditRequest,
|
|
56
|
+
MessageTokenItem,
|
|
57
|
+
MessageTokenOption,
|
|
58
|
+
MpItem,
|
|
59
|
+
PageQuery,
|
|
60
|
+
PageResult,
|
|
61
|
+
PreDetail,
|
|
62
|
+
PreItem,
|
|
63
|
+
PreSaveRequest,
|
|
64
|
+
PreTestRequest,
|
|
65
|
+
SendCount,
|
|
66
|
+
SendMessageResult,
|
|
67
|
+
SendRequest,
|
|
68
|
+
TopicAddRequest,
|
|
69
|
+
TopicDetail,
|
|
70
|
+
TopicEditRequest,
|
|
71
|
+
TopicItem,
|
|
72
|
+
TopicListQuery,
|
|
73
|
+
TopicQrCode,
|
|
74
|
+
TopicUserInfo,
|
|
75
|
+
TopicUserItem,
|
|
76
|
+
TopicUserListQuery,
|
|
77
|
+
UserDefaultDetail,
|
|
78
|
+
UserDefaultItem,
|
|
79
|
+
UserDefaultSaveRequest,
|
|
80
|
+
UserInfo,
|
|
81
|
+
UserLimitTime,
|
|
82
|
+
WebhookItem,
|
|
83
|
+
WebhookSaveRequest,
|
|
84
|
+
)
|
|
85
|
+
from .rate_limit import RateLimitGuard
|
|
86
|
+
|
|
87
|
+
__version__ = "1.0.0"
|
|
88
|
+
|
|
89
|
+
__all__ = [
|
|
90
|
+
"__version__",
|
|
91
|
+
"PushPlusClient",
|
|
92
|
+
"PushPlusClientBuilder",
|
|
93
|
+
"PushPlusConfig",
|
|
94
|
+
"DEFAULT_BASE_URL",
|
|
95
|
+
"PushPlusError",
|
|
96
|
+
"PushPlusException",
|
|
97
|
+
"AccessKeyManager",
|
|
98
|
+
"RateLimitGuard",
|
|
99
|
+
"HttpRequester",
|
|
100
|
+
"HttpResponse",
|
|
101
|
+
"RequestsHttpRequester",
|
|
102
|
+
"callback_parser",
|
|
103
|
+
"Channel",
|
|
104
|
+
"Template",
|
|
105
|
+
"CallbackEvent",
|
|
106
|
+
"SendStatus",
|
|
107
|
+
"WebhookType",
|
|
108
|
+
"ErrorCode",
|
|
109
|
+
"ApiResponse",
|
|
110
|
+
"PageQuery",
|
|
111
|
+
"PageResult",
|
|
112
|
+
"SendRequest",
|
|
113
|
+
"BatchSendRequest",
|
|
114
|
+
"BatchSendResult",
|
|
115
|
+
"CallbackPayload",
|
|
116
|
+
"MessageCompleteInfo",
|
|
117
|
+
"TopicUserInfo",
|
|
118
|
+
"CallbackFriendInfo",
|
|
119
|
+
"AccessKeyResult",
|
|
120
|
+
"MessageItem",
|
|
121
|
+
"SendMessageResult",
|
|
122
|
+
"UserInfo",
|
|
123
|
+
"UserLimitTime",
|
|
124
|
+
"SendCount",
|
|
125
|
+
"MessageTokenItem",
|
|
126
|
+
"MessageTokenOption",
|
|
127
|
+
"MessageTokenAddRequest",
|
|
128
|
+
"MessageTokenEditRequest",
|
|
129
|
+
"TopicItem",
|
|
130
|
+
"TopicDetail",
|
|
131
|
+
"TopicQrCode",
|
|
132
|
+
"TopicListQuery",
|
|
133
|
+
"TopicUserListQuery",
|
|
134
|
+
"TopicUserItem",
|
|
135
|
+
"TopicAddRequest",
|
|
136
|
+
"TopicEditRequest",
|
|
137
|
+
"FriendItem",
|
|
138
|
+
"FriendQrCode",
|
|
139
|
+
"WebhookItem",
|
|
140
|
+
"WebhookSaveRequest",
|
|
141
|
+
"MpItem",
|
|
142
|
+
"CpItem",
|
|
143
|
+
"MailItem",
|
|
144
|
+
"MailDetail",
|
|
145
|
+
"ClawBotQrCode",
|
|
146
|
+
"ClawBotInfo",
|
|
147
|
+
"ClawBotMessage",
|
|
148
|
+
"UserDefaultItem",
|
|
149
|
+
"UserDefaultDetail",
|
|
150
|
+
"UserDefaultSaveRequest",
|
|
151
|
+
"PreItem",
|
|
152
|
+
"PreDetail",
|
|
153
|
+
"PreSaveRequest",
|
|
154
|
+
"PreTestRequest",
|
|
155
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""AccessKey 管理器:缓存 + 过期前自动刷新 + 多线程安全。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import TYPE_CHECKING, Optional
|
|
8
|
+
|
|
9
|
+
from .config import PushPlusConfig
|
|
10
|
+
from .exceptions import PushPlusError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .api.access_key import AccessKeyApi
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_logger = logging.getLogger("perk_pushplus")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AccessKeyManager:
|
|
20
|
+
"""提供线程安全的 AccessKey 缓存与过期前自动刷新能力。
|
|
21
|
+
|
|
22
|
+
在调用任意需要 ``access-key`` 的开放接口前,:class:`OpenApi` 基类会
|
|
23
|
+
自动通过本类拿到一个有效的 AccessKey。
|
|
24
|
+
|
|
25
|
+
刷新策略:在 ``expiresIn`` 到期前 :attr:`PushPlusConfig.access_key_refresh_ahead_seconds`
|
|
26
|
+
秒视为过期。文档说明老 key 在新 key 生成后 5 分钟内仍可用,因此默认 300 秒提前量足够安全。
|
|
27
|
+
|
|
28
|
+
线程安全:多线程并发调用 :meth:`get_access_key` 时仅会触发一次刷新。
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, config: PushPlusConfig, access_key_api: "AccessKeyApi") -> None:
|
|
32
|
+
self._config = config
|
|
33
|
+
self._api = access_key_api
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
self._cached_key: Optional[str] = None
|
|
36
|
+
self._expire_at: datetime = datetime.min
|
|
37
|
+
|
|
38
|
+
def get_access_key(self) -> str:
|
|
39
|
+
"""获取有效的 AccessKey。如已缓存且未过期则直接返回;否则触发刷新。"""
|
|
40
|
+
|
|
41
|
+
if self._is_valid():
|
|
42
|
+
return self._cached_key # type: ignore[return-value]
|
|
43
|
+
return self.refresh()
|
|
44
|
+
|
|
45
|
+
def refresh(self) -> str:
|
|
46
|
+
"""强制刷新。多线程并发调用时仅会真正发起一次刷新请求。"""
|
|
47
|
+
|
|
48
|
+
with self._lock:
|
|
49
|
+
if self._is_valid():
|
|
50
|
+
return self._cached_key # type: ignore[return-value]
|
|
51
|
+
result = self._api.get_access_key()
|
|
52
|
+
if result is None or not result.accessKey:
|
|
53
|
+
raise PushPlusError("获取 AccessKey 失败:返回为空")
|
|
54
|
+
ttl_seconds = int(result.expiresIn) if result.expiresIn is not None else 7200
|
|
55
|
+
ahead = max(0, int(self._config.access_key_refresh_ahead_seconds))
|
|
56
|
+
effective_ttl = max(1, ttl_seconds - ahead)
|
|
57
|
+
self._cached_key = result.accessKey
|
|
58
|
+
self._expire_at = datetime.now() + timedelta(seconds=effective_ttl)
|
|
59
|
+
_logger.debug(
|
|
60
|
+
"[pushplus] AccessKey refreshed, ttl=%ss, refresh in <= %ss",
|
|
61
|
+
ttl_seconds,
|
|
62
|
+
effective_ttl,
|
|
63
|
+
)
|
|
64
|
+
return self._cached_key
|
|
65
|
+
|
|
66
|
+
def invalidate(self) -> None:
|
|
67
|
+
"""失效缓存。下次调用 :meth:`get_access_key` 时会重新拉取。"""
|
|
68
|
+
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._cached_key = None
|
|
71
|
+
self._expire_at = datetime.min
|
|
72
|
+
|
|
73
|
+
def _is_valid(self) -> bool:
|
|
74
|
+
return self._cached_key is not None and datetime.now() < self._expire_at
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["AccessKeyManager"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""API 子包入口。"""
|
|
2
|
+
from .access_key import AccessKeyApi
|
|
3
|
+
from .base import AbstractApi, OpenAbstractApi
|
|
4
|
+
from .channel import ChannelApi
|
|
5
|
+
from .clawbot import ClawBotApi
|
|
6
|
+
from .friend import FriendApi
|
|
7
|
+
from .message import MessageApi
|
|
8
|
+
from .message_token import MessageTokenApi
|
|
9
|
+
from .open_message import OpenMessageApi
|
|
10
|
+
from .pre import PreApi
|
|
11
|
+
from .setting import SettingApi
|
|
12
|
+
from .topic import TopicApi
|
|
13
|
+
from .topic_user import TopicUserApi
|
|
14
|
+
from .user import UserApi
|
|
15
|
+
from .webhook import WebhookApi
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AbstractApi",
|
|
19
|
+
"OpenAbstractApi",
|
|
20
|
+
"AccessKeyApi",
|
|
21
|
+
"MessageApi",
|
|
22
|
+
"OpenMessageApi",
|
|
23
|
+
"UserApi",
|
|
24
|
+
"MessageTokenApi",
|
|
25
|
+
"TopicApi",
|
|
26
|
+
"TopicUserApi",
|
|
27
|
+
"FriendApi",
|
|
28
|
+
"WebhookApi",
|
|
29
|
+
"ChannelApi",
|
|
30
|
+
"ClawBotApi",
|
|
31
|
+
"SettingApi",
|
|
32
|
+
"PreApi",
|
|
33
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""开放接口 - 获取 AccessKey。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ..config import PushPlusConfig
|
|
7
|
+
from ..exceptions import PushPlusError
|
|
8
|
+
from ..http import HttpRequester
|
|
9
|
+
from ..models import AccessKeyResult
|
|
10
|
+
from .base import AbstractApi
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AccessKeyApi(AbstractApi):
|
|
14
|
+
"""对应文档「一. 获取 AccessKey」。"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, config: PushPlusConfig, http: HttpRequester) -> None:
|
|
17
|
+
super().__init__(config, http)
|
|
18
|
+
|
|
19
|
+
def get_access_key(
|
|
20
|
+
self,
|
|
21
|
+
token: Optional[str] = None,
|
|
22
|
+
secret_key: Optional[str] = None,
|
|
23
|
+
) -> AccessKeyResult:
|
|
24
|
+
"""获取 AccessKey。
|
|
25
|
+
|
|
26
|
+
``token`` / ``secret_key`` 不传时使用 :class:`PushPlusConfig` 中的配置。
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
token = token or self.config.token
|
|
30
|
+
secret_key = secret_key or self.config.secret_key
|
|
31
|
+
if not token:
|
|
32
|
+
raise PushPlusError("获取 AccessKey 需要 token")
|
|
33
|
+
if not secret_key:
|
|
34
|
+
raise PushPlusError("获取 AccessKey 需要 secretKey")
|
|
35
|
+
body = {"token": token, "secretKey": secret_key}
|
|
36
|
+
result = self.execute_for_data(
|
|
37
|
+
"POST", "/api/common/openApi/getAccessKey", None, body, AccessKeyResult
|
|
38
|
+
)
|
|
39
|
+
if result is None:
|
|
40
|
+
raise PushPlusError("获取 AccessKey 失败:返回为空")
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
__all__ = ["AccessKeyApi"]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""API 基类:提供请求执行、统一错误处理、access-key 自动注入与重试。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Type, TypeVar
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from ..config import PushPlusConfig
|
|
9
|
+
from ..exceptions import PushPlusError
|
|
10
|
+
from ..http import HttpRequester, to_json
|
|
11
|
+
from ..models.base import ApiResponse, model_from_dict, model_to_dict
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..access_key import AccessKeyManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
HEADER_ACCESS_KEY = "access-key"
|
|
19
|
+
_CODE_ACCESS_KEY_INVALID = 401
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AbstractApi:
|
|
23
|
+
"""API 基类。"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, config: PushPlusConfig, http: HttpRequester) -> None:
|
|
26
|
+
self.config = config
|
|
27
|
+
self.http = http
|
|
28
|
+
|
|
29
|
+
def resolve_url(self, path: str) -> str:
|
|
30
|
+
"""拼接绝对 URL。"""
|
|
31
|
+
|
|
32
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
33
|
+
return path
|
|
34
|
+
base = self.config.resolve_base_url()
|
|
35
|
+
return base + (path if path.startswith("/") else "/" + path)
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def build_query(params: Optional[Mapping[str, Any]]) -> str:
|
|
39
|
+
"""把 mapping 拼成 query string;忽略 ``None`` 值。"""
|
|
40
|
+
|
|
41
|
+
if not params:
|
|
42
|
+
return ""
|
|
43
|
+
parts = []
|
|
44
|
+
for key, value in params.items():
|
|
45
|
+
if value is None:
|
|
46
|
+
continue
|
|
47
|
+
parts.append(f"{quote(str(key), safe='')}={quote(str(value), safe='')}")
|
|
48
|
+
return "&".join(parts)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def append_query(cls, path: str, params: Optional[Mapping[str, Any]]) -> str:
|
|
52
|
+
"""在 path 上追加 query string。"""
|
|
53
|
+
|
|
54
|
+
q = cls.build_query(params)
|
|
55
|
+
if not q:
|
|
56
|
+
return path
|
|
57
|
+
sep = "&" if "?" in path else "?"
|
|
58
|
+
return f"{path}{sep}{q}"
|
|
59
|
+
|
|
60
|
+
def execute(
|
|
61
|
+
self,
|
|
62
|
+
method: str,
|
|
63
|
+
path: str,
|
|
64
|
+
headers: Optional[Dict[str, str]],
|
|
65
|
+
body: Any,
|
|
66
|
+
data_type: Optional[Type[T]] = None,
|
|
67
|
+
) -> ApiResponse[T]:
|
|
68
|
+
"""执行请求并返回原始 :class:`ApiResponse`(不进行 code 校验)。"""
|
|
69
|
+
|
|
70
|
+
url = self.resolve_url(path)
|
|
71
|
+
body_json: Optional[str]
|
|
72
|
+
if body is None:
|
|
73
|
+
body_json = None
|
|
74
|
+
elif isinstance(body, str):
|
|
75
|
+
body_json = body
|
|
76
|
+
else:
|
|
77
|
+
body_json = to_json(model_to_dict(body))
|
|
78
|
+
resp = self.http.execute(method, url, headers, body_json)
|
|
79
|
+
if not resp.is_successful:
|
|
80
|
+
raise PushPlusError(
|
|
81
|
+
f"PushPlus 接口 HTTP 调用失败: status={resp.status_code}, body={resp.body}",
|
|
82
|
+
code=resp.status_code,
|
|
83
|
+
)
|
|
84
|
+
return self._parse_api_response(resp.body, data_type)
|
|
85
|
+
|
|
86
|
+
def execute_for_data(
|
|
87
|
+
self,
|
|
88
|
+
method: str,
|
|
89
|
+
path: str,
|
|
90
|
+
headers: Optional[Dict[str, str]],
|
|
91
|
+
body: Any,
|
|
92
|
+
data_type: Optional[Type[T]] = None,
|
|
93
|
+
) -> Optional[T]:
|
|
94
|
+
"""执行请求并直接返回 ``data``;非 200 抛出异常。"""
|
|
95
|
+
|
|
96
|
+
resp = self.execute(method, path, headers, body, data_type)
|
|
97
|
+
if not resp.is_success():
|
|
98
|
+
raise PushPlusError(
|
|
99
|
+
self._build_business_error_message("PushPlus 接口业务失败", resp),
|
|
100
|
+
code=resp.code if resp.code is not None else -1,
|
|
101
|
+
)
|
|
102
|
+
return resp.data
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _parse_api_response(
|
|
106
|
+
response_body: str, data_type: Optional[Type[T]] = None
|
|
107
|
+
) -> ApiResponse[T]:
|
|
108
|
+
if response_body is None or response_body == "":
|
|
109
|
+
raise PushPlusError("PushPlus 接口返回为空")
|
|
110
|
+
try:
|
|
111
|
+
payload = json.loads(response_body)
|
|
112
|
+
except (TypeError, ValueError) as exc:
|
|
113
|
+
raise PushPlusError(
|
|
114
|
+
f"解析 PushPlus 响应 JSON 失败: {exc}, payload={response_body}", cause=exc
|
|
115
|
+
) from exc
|
|
116
|
+
if not isinstance(payload, dict):
|
|
117
|
+
raise PushPlusError(f"PushPlus 接口返回非 JSON 对象: {response_body}")
|
|
118
|
+
|
|
119
|
+
result: ApiResponse[T] = ApiResponse(
|
|
120
|
+
code=payload.get("code"), msg=payload.get("msg"), data=None
|
|
121
|
+
)
|
|
122
|
+
data_node = payload.get("data")
|
|
123
|
+
if result.is_success():
|
|
124
|
+
if data_node is None:
|
|
125
|
+
result.data = None
|
|
126
|
+
return result
|
|
127
|
+
try:
|
|
128
|
+
if data_type is None:
|
|
129
|
+
result.data = data_node
|
|
130
|
+
else:
|
|
131
|
+
result.data = model_from_dict(data_type, data_node)
|
|
132
|
+
except Exception as exc: # noqa: BLE001
|
|
133
|
+
raise PushPlusError(
|
|
134
|
+
f"解析 PushPlus 响应 data 字段失败: {exc}, payload={response_body}",
|
|
135
|
+
cause=exc,
|
|
136
|
+
) from exc
|
|
137
|
+
return result
|
|
138
|
+
# 业务失败:把 data 中的字符串/简单值附加到 msg。
|
|
139
|
+
data_text = AbstractApi._extract_data_text(data_node)
|
|
140
|
+
if data_text:
|
|
141
|
+
if not result.msg:
|
|
142
|
+
result.msg = data_text
|
|
143
|
+
elif data_text not in result.msg:
|
|
144
|
+
result.msg = f"{result.msg}: {data_text}"
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _extract_data_text(data_node: Any) -> Optional[str]:
|
|
149
|
+
if data_node is None:
|
|
150
|
+
return None
|
|
151
|
+
if isinstance(data_node, (str, int, float, bool)):
|
|
152
|
+
return str(data_node)
|
|
153
|
+
try:
|
|
154
|
+
return json.dumps(data_node, ensure_ascii=False)
|
|
155
|
+
except Exception: # noqa: BLE001
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _build_business_error_message(prefix: str, resp: ApiResponse) -> str:
|
|
160
|
+
return f"{prefix}: code={resp.code}, msg={resp.msg}"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class OpenAbstractApi(AbstractApi):
|
|
164
|
+
"""开放接口基类。
|
|
165
|
+
|
|
166
|
+
会自动在 header 中带上 ``access-key``,并在收到 ``code=401`` 时尝试刷新 AccessKey 后重试一次。
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
config: PushPlusConfig,
|
|
172
|
+
http: HttpRequester,
|
|
173
|
+
access_key_manager: "AccessKeyManager",
|
|
174
|
+
) -> None:
|
|
175
|
+
super().__init__(config, http)
|
|
176
|
+
self.access_key_manager = access_key_manager
|
|
177
|
+
|
|
178
|
+
def _headers_with_access_key(self) -> Dict[str, str]:
|
|
179
|
+
return {HEADER_ACCESS_KEY: self.access_key_manager.get_access_key()}
|
|
180
|
+
|
|
181
|
+
def execute_open(
|
|
182
|
+
self,
|
|
183
|
+
method: str,
|
|
184
|
+
path: str,
|
|
185
|
+
body: Any = None,
|
|
186
|
+
data_type: Optional[Type[T]] = None,
|
|
187
|
+
) -> Optional[T]:
|
|
188
|
+
"""执行带 ``access-key`` 的请求;当返回 ``code=401`` 时自动刷新 key 并重试一次。"""
|
|
189
|
+
|
|
190
|
+
headers = self._headers_with_access_key()
|
|
191
|
+
resp = self.execute(method, path, headers, body, data_type)
|
|
192
|
+
if resp.is_success():
|
|
193
|
+
return resp.data
|
|
194
|
+
if resp.code is not None and int(resp.code) == _CODE_ACCESS_KEY_INVALID:
|
|
195
|
+
self.access_key_manager.invalidate()
|
|
196
|
+
retry_headers = self._headers_with_access_key()
|
|
197
|
+
retry = self.execute(method, path, retry_headers, body, data_type)
|
|
198
|
+
if retry.is_success():
|
|
199
|
+
return retry.data
|
|
200
|
+
raise PushPlusError(
|
|
201
|
+
f"PushPlus 开放接口业务失败(重试后): code={retry.code}, msg={retry.msg}",
|
|
202
|
+
code=retry.code if retry.code is not None else -1,
|
|
203
|
+
)
|
|
204
|
+
raise PushPlusError(
|
|
205
|
+
f"PushPlus 开放接口业务失败: code={resp.code}, msg={resp.msg}",
|
|
206
|
+
code=resp.code if resp.code is not None else -1,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
__all__ = ["AbstractApi", "OpenAbstractApi", "HEADER_ACCESS_KEY"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""开放接口 - 微信公众号/企业微信/邮箱渠道列表(文档「七. 渠道配置接口」 5-8)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ..models import CpItem, MailDetail, MailItem, MpItem, PageQuery, PageResult
|
|
7
|
+
from .base import OpenAbstractApi
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChannelApi(OpenAbstractApi):
|
|
11
|
+
"""微信公众号 / 企业微信 / 邮箱渠道列表。"""
|
|
12
|
+
|
|
13
|
+
def mp_list(self, query: Optional[PageQuery] = None) -> PageResult[MpItem]:
|
|
14
|
+
"""微信公众号渠道列表。"""
|
|
15
|
+
|
|
16
|
+
body = query if query is not None else PageQuery()
|
|
17
|
+
result = self.execute_open(
|
|
18
|
+
"POST", "/api/open/mp/list", body, PageResult[MpItem]
|
|
19
|
+
)
|
|
20
|
+
return result or PageResult(list=[])
|
|
21
|
+
|
|
22
|
+
def cp_list(self, query: Optional[PageQuery] = None) -> PageResult[CpItem]:
|
|
23
|
+
"""企业微信应用渠道列表。"""
|
|
24
|
+
|
|
25
|
+
body = query if query is not None else PageQuery()
|
|
26
|
+
result = self.execute_open(
|
|
27
|
+
"POST", "/api/open/cp/list", body, PageResult[CpItem]
|
|
28
|
+
)
|
|
29
|
+
return result or PageResult(list=[])
|
|
30
|
+
|
|
31
|
+
def mail_list(self, query: Optional[PageQuery] = None) -> PageResult[MailItem]:
|
|
32
|
+
"""邮箱渠道列表。"""
|
|
33
|
+
|
|
34
|
+
body = query if query is not None else PageQuery()
|
|
35
|
+
result = self.execute_open(
|
|
36
|
+
"POST", "/api/open/mail/list", body, PageResult[MailItem]
|
|
37
|
+
)
|
|
38
|
+
return result or PageResult(list=[])
|
|
39
|
+
|
|
40
|
+
def mail_detail(self, mail_id: int) -> MailDetail:
|
|
41
|
+
"""邮箱渠道详情。"""
|
|
42
|
+
|
|
43
|
+
path = self.append_query("/api/open/mail/detail", {"mailId": int(mail_id)})
|
|
44
|
+
return self.execute_open("GET", path, None, MailDetail)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["ChannelApi"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""开放接口 - 微信 ClawBot(文档「八. 微信ClawBot接口」)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from ..models import ClawBotInfo, ClawBotMessage, ClawBotQrCode
|
|
7
|
+
from .base import OpenAbstractApi
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClawBotApi(OpenAbstractApi):
|
|
11
|
+
"""微信 ClawBot 渠道相关接口。"""
|
|
12
|
+
|
|
13
|
+
def get_bot_qrcode(self) -> ClawBotQrCode:
|
|
14
|
+
"""获取二维码。"""
|
|
15
|
+
|
|
16
|
+
return self.execute_open(
|
|
17
|
+
"GET", "/api/open/clawBot/getBotQrcode", None, ClawBotQrCode
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def get_qrcode_status(self, qrcode: str) -> None:
|
|
21
|
+
"""扫码结果查询。"""
|
|
22
|
+
|
|
23
|
+
path = self.append_query(
|
|
24
|
+
"/api/open/clawBot/getQrcodeStatus", {"getQrcodeStatus": qrcode}
|
|
25
|
+
)
|
|
26
|
+
self.execute_open("GET", path, None, None)
|
|
27
|
+
|
|
28
|
+
def bot_info(self) -> ClawBotInfo:
|
|
29
|
+
"""绑定详情。"""
|
|
30
|
+
|
|
31
|
+
return self.execute_open(
|
|
32
|
+
"GET", "/api/open/clawBot/botInfo", None, ClawBotInfo
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def unbind(self) -> None:
|
|
36
|
+
"""解绑。"""
|
|
37
|
+
|
|
38
|
+
self.execute_open("GET", "/api/open/clawBot/unbind", None, None)
|
|
39
|
+
|
|
40
|
+
def get_msg(self) -> List[ClawBotMessage]:
|
|
41
|
+
"""获取发送消息。"""
|
|
42
|
+
|
|
43
|
+
result = self.execute_open(
|
|
44
|
+
"GET", "/api/open/clawBot/getMsg", None, List[ClawBotMessage]
|
|
45
|
+
)
|
|
46
|
+
return result or []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["ClawBotApi"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""开放接口 - 好友功能(文档「十. 好友功能接口」)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ..models import FriendItem, FriendQrCode, PageQuery, PageResult
|
|
7
|
+
from .base import OpenAbstractApi
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FriendApi(OpenAbstractApi):
|
|
11
|
+
"""好友相关接口。"""
|
|
12
|
+
|
|
13
|
+
def get_qr_code(
|
|
14
|
+
self,
|
|
15
|
+
app_id: Optional[str] = None,
|
|
16
|
+
content: Optional[str] = None,
|
|
17
|
+
second: Optional[int] = None,
|
|
18
|
+
scan_count: Optional[int] = None,
|
|
19
|
+
) -> FriendQrCode:
|
|
20
|
+
"""获取个人二维码。"""
|
|
21
|
+
|
|
22
|
+
params = {}
|
|
23
|
+
if app_id is not None:
|
|
24
|
+
params["appId"] = app_id
|
|
25
|
+
if content is not None:
|
|
26
|
+
params["content"] = content
|
|
27
|
+
if second is not None:
|
|
28
|
+
params["second"] = int(second)
|
|
29
|
+
if scan_count is not None:
|
|
30
|
+
params["scanCount"] = int(scan_count)
|
|
31
|
+
path = self.append_query("/api/open/friend/getQrCode", params)
|
|
32
|
+
return self.execute_open("GET", path, None, FriendQrCode)
|
|
33
|
+
|
|
34
|
+
def list(self, query: Optional[PageQuery] = None) -> PageResult[FriendItem]:
|
|
35
|
+
"""获取好友列表。"""
|
|
36
|
+
|
|
37
|
+
body = query if query is not None else PageQuery()
|
|
38
|
+
result = self.execute_open(
|
|
39
|
+
"POST", "/api/open/friend/list", body, PageResult[FriendItem]
|
|
40
|
+
)
|
|
41
|
+
return result or PageResult(list=[])
|
|
42
|
+
|
|
43
|
+
def delete(self, friend_id: int) -> None:
|
|
44
|
+
"""删除好友。"""
|
|
45
|
+
|
|
46
|
+
path = self.append_query(
|
|
47
|
+
"/api/open/friend/deleteFriend", {"friendId": int(friend_id)}
|
|
48
|
+
)
|
|
49
|
+
self.execute_open("GET", path, None, None)
|
|
50
|
+
|
|
51
|
+
def edit_remark(self, id_: int, remark: str) -> None:
|
|
52
|
+
"""修改好友备注。"""
|
|
53
|
+
|
|
54
|
+
body = {"id": int(id_), "remark": remark}
|
|
55
|
+
self.execute_open("POST", "/api/open/friend/editRemark", body, None)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = ["FriendApi"]
|