rcs-openapi-client 0.5.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.
- rcs_openapi/__init__.py +43 -0
- rcs_openapi/client.py +473 -0
- rcs_openapi/errors.py +37 -0
- rcs_openapi/pii.py +59 -0
- rcs_openapi/py.typed +0 -0
- rcs_openapi/rules.py +99 -0
- rcs_openapi/types.py +82 -0
- rcs_openapi/webhook.py +241 -0
- rcs_openapi_client-0.5.0.dist-info/METADATA +221 -0
- rcs_openapi_client-0.5.0.dist-info/RECORD +13 -0
- rcs_openapi_client-0.5.0.dist-info/WHEEL +5 -0
- rcs_openapi_client-0.5.0.dist-info/licenses/LICENSE +3 -0
- rcs_openapi_client-0.5.0.dist-info/top_level.txt +1 -0
rcs_openapi/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""RCS Open API Python SDK。"""
|
|
2
|
+
|
|
3
|
+
from .client import RcsOpenApiClient
|
|
4
|
+
from .errors import (
|
|
5
|
+
PiiDecryptionError,
|
|
6
|
+
RcsOpenApiError,
|
|
7
|
+
RcsProtocolError,
|
|
8
|
+
RcsTransportError,
|
|
9
|
+
WebhookError,
|
|
10
|
+
)
|
|
11
|
+
from .pii import PiiDecryptor
|
|
12
|
+
from .rules import LeadIntakeRule
|
|
13
|
+
from .types import (
|
|
14
|
+
CurrentUser,
|
|
15
|
+
Customer,
|
|
16
|
+
CustomerInput,
|
|
17
|
+
CustomField,
|
|
18
|
+
JsonObject,
|
|
19
|
+
ListResponse,
|
|
20
|
+
PartnerBinding,
|
|
21
|
+
)
|
|
22
|
+
from .webhook import WebhookEvent, WebhookReceiver
|
|
23
|
+
|
|
24
|
+
__version__ = "0.5.0"
|
|
25
|
+
__all__ = [
|
|
26
|
+
"RcsOpenApiClient",
|
|
27
|
+
"RcsOpenApiError",
|
|
28
|
+
"RcsProtocolError",
|
|
29
|
+
"RcsTransportError",
|
|
30
|
+
"PiiDecryptor",
|
|
31
|
+
"PiiDecryptionError",
|
|
32
|
+
"WebhookReceiver",
|
|
33
|
+
"WebhookEvent",
|
|
34
|
+
"WebhookError",
|
|
35
|
+
"LeadIntakeRule",
|
|
36
|
+
"CurrentUser",
|
|
37
|
+
"PartnerBinding",
|
|
38
|
+
"Customer",
|
|
39
|
+
"CustomerInput",
|
|
40
|
+
"CustomField",
|
|
41
|
+
"JsonObject",
|
|
42
|
+
"ListResponse",
|
|
43
|
+
]
|
rcs_openapi/client.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""同步开放 API 客户端:不自动重试写入、不自动跟随重定向。"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, Self, cast
|
|
10
|
+
from urllib.parse import quote, urlsplit
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .errors import RcsOpenApiError, RcsProtocolError, RcsTransportError
|
|
15
|
+
from .pii import CUSTOMER_PII_FIELDS, PiiDecryptor
|
|
16
|
+
from .rules import LeadIntakeRule
|
|
17
|
+
from .types import CurrentUser, Customer, JsonObject, ListResponse
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _url(value: str, *, base: bool = False) -> str:
|
|
21
|
+
try:
|
|
22
|
+
parsed = urlsplit(value)
|
|
23
|
+
valid = parsed.scheme == "https" or (
|
|
24
|
+
parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
|
25
|
+
)
|
|
26
|
+
if (
|
|
27
|
+
not valid
|
|
28
|
+
or not parsed.hostname
|
|
29
|
+
or parsed.username is not None
|
|
30
|
+
or parsed.password is not None
|
|
31
|
+
):
|
|
32
|
+
raise ValueError
|
|
33
|
+
_ = parsed.port
|
|
34
|
+
if "#" in value or (base and ("?" in value or parsed.path not in ("", "/"))):
|
|
35
|
+
raise ValueError
|
|
36
|
+
except (ValueError, TypeError, AttributeError):
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"URL 必须为 HTTPS 根地址(仅本机环回测试允许 HTTP),不得含用户名或密码"
|
|
39
|
+
) from None
|
|
40
|
+
return value.rstrip("/") if base else value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _id(value: str) -> str:
|
|
44
|
+
if not isinstance(value, str) or not value or value in (".", ".."):
|
|
45
|
+
raise ValueError("资源 ID 不得为空或相对路径片段")
|
|
46
|
+
return quote(value, safe="")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RcsOpenApiClient:
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
base_url: str,
|
|
53
|
+
*,
|
|
54
|
+
access_key_id: str | None = None,
|
|
55
|
+
secret_key: str | None = None,
|
|
56
|
+
access_token: str | None = None,
|
|
57
|
+
pii_aes_key: str | None = None,
|
|
58
|
+
timeout: float = 15,
|
|
59
|
+
transport: httpx.BaseTransport | None = None,
|
|
60
|
+
partner_id: str | None = None,
|
|
61
|
+
partner_client_id: str | None = None,
|
|
62
|
+
partner_client_secret: str | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self._base_url = _url(base_url, base=True)
|
|
65
|
+
if isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0:
|
|
66
|
+
raise ValueError("timeout 必须为正的有限秒数")
|
|
67
|
+
self._access_key_id = access_key_id
|
|
68
|
+
self._secret_key = secret_key
|
|
69
|
+
self._access_token = access_token
|
|
70
|
+
self._partner_id = partner_id
|
|
71
|
+
self._partner_client_id = partner_client_id
|
|
72
|
+
self._partner_client_secret = partner_client_secret
|
|
73
|
+
self._pii = PiiDecryptor(pii_aes_key) if pii_aes_key is not None else None
|
|
74
|
+
self._http = httpx.Client(timeout=timeout, transport=transport, follow_redirects=False)
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> Self:
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def __exit__(
|
|
80
|
+
self,
|
|
81
|
+
exc_type: type[BaseException] | None,
|
|
82
|
+
exc: BaseException | None,
|
|
83
|
+
traceback: TracebackType | None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
def close(self) -> None:
|
|
88
|
+
self._http.close()
|
|
89
|
+
|
|
90
|
+
def authenticate(self) -> str:
|
|
91
|
+
if not self._access_key_id or not self._secret_key:
|
|
92
|
+
raise ValueError("缺少 access_key_id / secret_key")
|
|
93
|
+
data = self._request(
|
|
94
|
+
"POST",
|
|
95
|
+
"/auth/token",
|
|
96
|
+
body={"accessKeyId": self._access_key_id, "secretKey": self._secret_key},
|
|
97
|
+
auth=False,
|
|
98
|
+
)
|
|
99
|
+
token = data.get("accessToken")
|
|
100
|
+
if not isinstance(token, str) or not token:
|
|
101
|
+
raise RcsProtocolError("换 token 响应缺少有效 accessToken")
|
|
102
|
+
self._access_token = token
|
|
103
|
+
return token
|
|
104
|
+
|
|
105
|
+
def send_partner_login_sms(self, phone: str) -> JsonObject:
|
|
106
|
+
"""申请合作方验证码,返回 challengeId。"""
|
|
107
|
+
return self._post_partner_login("/sms/send", {"phone": phone})
|
|
108
|
+
|
|
109
|
+
def submit_partner_login(
|
|
110
|
+
self, input: Mapping[str, Any], idempotency_key: str
|
|
111
|
+
) -> JsonObject:
|
|
112
|
+
"""提交密码或短信登录;同一请求重试复用幂等键。"""
|
|
113
|
+
if not re.fullmatch(r"[A-Za-z0-9._:-]{8,128}", idempotency_key):
|
|
114
|
+
raise ValueError("幂等键格式不正确")
|
|
115
|
+
return self._post_partner_login("", input, idempotency_key)
|
|
116
|
+
|
|
117
|
+
def exchange_partner_login_code(self, code: str) -> JsonObject:
|
|
118
|
+
"""兑换一次性码;令牌由调用方按用户保存,客户端不自动重试。"""
|
|
119
|
+
return self._post_partner_login("/exchange", {"code": code})
|
|
120
|
+
|
|
121
|
+
def _post_partner_login(
|
|
122
|
+
self, path: str, body: Mapping[str, Any], idempotency_key: str | None = None
|
|
123
|
+
) -> JsonObject:
|
|
124
|
+
if not self._partner_id or not self._partner_client_id or not self._partner_client_secret:
|
|
125
|
+
raise ValueError("缺少合作方登录凭证")
|
|
126
|
+
if urlsplit(self._base_url).scheme != "https":
|
|
127
|
+
raise ValueError("合作方登录必须使用 HTTPS")
|
|
128
|
+
basic = base64.b64encode(
|
|
129
|
+
f"{self._partner_client_id}:{self._partner_client_secret}".encode()
|
|
130
|
+
).decode("ascii")
|
|
131
|
+
headers = {"Authorization": "Basic " + basic}
|
|
132
|
+
if idempotency_key is not None:
|
|
133
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
134
|
+
return self._request(
|
|
135
|
+
"POST", "/auth/partner-login" + path,
|
|
136
|
+
body={**body, "partner_id": self._partner_id}, auth=False, extra_headers=headers,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def set_access_token(self, token: str) -> None:
|
|
140
|
+
if not isinstance(token, str) or not token:
|
|
141
|
+
raise ValueError("token 不得为空")
|
|
142
|
+
self._access_token = token
|
|
143
|
+
|
|
144
|
+
def _send(
|
|
145
|
+
self,
|
|
146
|
+
method: str,
|
|
147
|
+
url: str,
|
|
148
|
+
*,
|
|
149
|
+
headers: Mapping[str, str] | None = None,
|
|
150
|
+
params: dict[str, Any] | None = None,
|
|
151
|
+
body: Mapping[str, Any] | None = None,
|
|
152
|
+
) -> httpx.Response:
|
|
153
|
+
try:
|
|
154
|
+
response = self._http.request(
|
|
155
|
+
method,
|
|
156
|
+
url,
|
|
157
|
+
headers=headers,
|
|
158
|
+
params=params,
|
|
159
|
+
json=dict(body) if body is not None else None,
|
|
160
|
+
)
|
|
161
|
+
except httpx.RequestError:
|
|
162
|
+
raise RcsTransportError(
|
|
163
|
+
"HTTP 请求失败(连接、TLS 或超时);写入结果可能未知,请先回查"
|
|
164
|
+
) from None
|
|
165
|
+
if 300 <= response.status_code < 400:
|
|
166
|
+
raise RcsProtocolError("接口返回意外重定向,SDK 未跟随")
|
|
167
|
+
return response
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _error(response: httpx.Response) -> RcsOpenApiError:
|
|
171
|
+
try:
|
|
172
|
+
data = response.json()
|
|
173
|
+
error = data.get("error", {}) if isinstance(data, dict) else {}
|
|
174
|
+
if not isinstance(error, dict):
|
|
175
|
+
error = {}
|
|
176
|
+
except ValueError:
|
|
177
|
+
error = {}
|
|
178
|
+
code = error.get("code")
|
|
179
|
+
message = error.get("message")
|
|
180
|
+
return RcsOpenApiError(
|
|
181
|
+
code if isinstance(code, str) else "unknown",
|
|
182
|
+
message if isinstance(message, str) else "接口请求失败",
|
|
183
|
+
response.status_code,
|
|
184
|
+
request_id=error.get("requestId") if isinstance(error.get("requestId"), str) else None,
|
|
185
|
+
details=error.get("details"),
|
|
186
|
+
retryable=error.get("retryable") if isinstance(error.get("retryable"), bool) else None,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _request(
|
|
190
|
+
self,
|
|
191
|
+
method: str,
|
|
192
|
+
path: str,
|
|
193
|
+
params: Mapping[str, Any] | None = None,
|
|
194
|
+
body: Mapping[str, Any] | None = None,
|
|
195
|
+
*,
|
|
196
|
+
auth: bool = True,
|
|
197
|
+
existing_lead: bool = False,
|
|
198
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
199
|
+
) -> JsonObject:
|
|
200
|
+
headers = {"Accept": "application/json", **(extra_headers or {})}
|
|
201
|
+
if auth:
|
|
202
|
+
if not self._access_token:
|
|
203
|
+
raise ValueError("未鉴权:请先 authenticate() 或传入 access_token")
|
|
204
|
+
headers["Authorization"] = "Bearer " + self._access_token
|
|
205
|
+
query = {
|
|
206
|
+
k: str(v).lower() if isinstance(v, bool) else v
|
|
207
|
+
for k, v in (params or {}).items()
|
|
208
|
+
if v is not None and v != ""
|
|
209
|
+
}
|
|
210
|
+
response = self._send(
|
|
211
|
+
method, self._base_url + "/api/open/v1" + path, headers=headers, params=query, body=body
|
|
212
|
+
)
|
|
213
|
+
if response.status_code == 204:
|
|
214
|
+
return {}
|
|
215
|
+
try:
|
|
216
|
+
data = response.json()
|
|
217
|
+
except ValueError:
|
|
218
|
+
if response.is_error:
|
|
219
|
+
raise self._error(response) from None
|
|
220
|
+
raise RcsProtocolError("接口成功响应不是有效 JSON 对象") from None
|
|
221
|
+
if response.is_error:
|
|
222
|
+
if (
|
|
223
|
+
existing_lead
|
|
224
|
+
and response.status_code == 409
|
|
225
|
+
and isinstance(data, dict)
|
|
226
|
+
and isinstance(data.get("leadId"), str)
|
|
227
|
+
and data["leadId"]
|
|
228
|
+
and data.get("alreadyExisted") is True
|
|
229
|
+
):
|
|
230
|
+
return cast(JsonObject, data)
|
|
231
|
+
raise self._error(response)
|
|
232
|
+
if not isinstance(data, dict):
|
|
233
|
+
raise RcsProtocolError("接口成功响应必须是 JSON 对象")
|
|
234
|
+
return cast(JsonObject, data)
|
|
235
|
+
|
|
236
|
+
def create_outbound_call(self, input: Mapping[str, Any]) -> JsonObject:
|
|
237
|
+
"""提交任务;externalRef 由调用方提供,不自动重试或替换。"""
|
|
238
|
+
return self._request("POST", "/outbound-calls", body=input)
|
|
239
|
+
|
|
240
|
+
def get_outbound_call_task(self, task_id: str) -> JsonObject:
|
|
241
|
+
return self._request("GET", "/outbound-calls/" + _id(task_id))
|
|
242
|
+
|
|
243
|
+
def list_outbound_calls(self, params: Mapping[str, Any] | None = None) -> JsonObject:
|
|
244
|
+
return self._request("GET", "/outbound-calls", params)
|
|
245
|
+
|
|
246
|
+
def list_customers(self, params: Mapping[str, Any] | None = None) -> ListResponse[Customer]:
|
|
247
|
+
return cast(ListResponse[Customer], self._request("GET", "/customers", params))
|
|
248
|
+
|
|
249
|
+
def get_current_user(self) -> CurrentUser:
|
|
250
|
+
"""获取当前令牌对应的用户资料及适用的合作方绑定。"""
|
|
251
|
+
return cast(CurrentUser, self._request("GET", "/users/me"))
|
|
252
|
+
|
|
253
|
+
def get_customer(self, id: str) -> Customer:
|
|
254
|
+
return cast(Customer, self._request("GET", "/customers/" + _id(id)))
|
|
255
|
+
|
|
256
|
+
def create_customer(self, input: Mapping[str, Any]) -> Customer:
|
|
257
|
+
return cast(Customer, self._request("POST", "/customers", body=input))
|
|
258
|
+
|
|
259
|
+
def update_customer(self, id: str, patch: Mapping[str, Any]) -> Customer:
|
|
260
|
+
return cast(Customer, self._request("PATCH", "/customers/" + _id(id), body=patch))
|
|
261
|
+
|
|
262
|
+
def list_customer_fields(self) -> JsonObject:
|
|
263
|
+
return self._request("GET", "/customer-fields")
|
|
264
|
+
|
|
265
|
+
def set_do_not_disturb(
|
|
266
|
+
self, id: str, do_not_disturb: bool, reason: str | None = None
|
|
267
|
+
) -> Customer:
|
|
268
|
+
if not isinstance(do_not_disturb, bool):
|
|
269
|
+
raise ValueError("do_not_disturb 必须是布尔值")
|
|
270
|
+
body: JsonObject = {"doNotDisturb": do_not_disturb}
|
|
271
|
+
if do_not_disturb:
|
|
272
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
273
|
+
raise ValueError("开启免打扰必须提供原因")
|
|
274
|
+
body["dndReason"] = reason
|
|
275
|
+
return self.update_customer(id, body)
|
|
276
|
+
|
|
277
|
+
def list_agents(self, params: Mapping[str, Any] | None = None) -> JsonObject:
|
|
278
|
+
return self._request("GET", "/agents", params)
|
|
279
|
+
|
|
280
|
+
def get_agent(self, id: str) -> JsonObject:
|
|
281
|
+
return self._request("GET", "/agents/" + _id(id))
|
|
282
|
+
|
|
283
|
+
def create_agent(self, input: Mapping[str, Any]) -> JsonObject:
|
|
284
|
+
"""创建主体,必填 aliasName、storeAddress、entityType、name。"""
|
|
285
|
+
return self._request("POST", "/agents", body=input)
|
|
286
|
+
|
|
287
|
+
def delete_agent(self, id: str) -> None:
|
|
288
|
+
"""归档主体,未结束的营销计划引用时保留服务端 409。"""
|
|
289
|
+
self._request("DELETE", "/agents/" + _id(id))
|
|
290
|
+
|
|
291
|
+
def update_agent(self, id: str, patch: Mapping[str, Any]) -> JsonObject:
|
|
292
|
+
return self._request("PATCH", "/agents/" + _id(id), body=patch)
|
|
293
|
+
|
|
294
|
+
def list_campaigns(self, params: Mapping[str, Any] | None = None) -> JsonObject:
|
|
295
|
+
return self._request("GET", "/campaigns", params)
|
|
296
|
+
|
|
297
|
+
def get_campaign(self, id: str) -> JsonObject:
|
|
298
|
+
return self._request("GET", "/campaigns/" + _id(id))
|
|
299
|
+
|
|
300
|
+
def create_campaign(self, input: Mapping[str, Any]) -> JsonObject:
|
|
301
|
+
return self._request("POST", "/campaigns", body=input)
|
|
302
|
+
|
|
303
|
+
def update_campaign(self, id: str, patch: Mapping[str, Any]) -> JsonObject:
|
|
304
|
+
return self._request("PATCH", "/campaigns/" + _id(id), body=patch)
|
|
305
|
+
|
|
306
|
+
def rename_campaign(self, id: str, name: str) -> JsonObject:
|
|
307
|
+
return self.update_campaign(id, {"name": name})
|
|
308
|
+
|
|
309
|
+
def set_campaign_status(self, id: str, target: str) -> JsonObject:
|
|
310
|
+
return self.update_campaign(id, {"status": target})
|
|
311
|
+
|
|
312
|
+
def set_campaign_lead_intake_rule(
|
|
313
|
+
self, id: str, lead_intake_rule: Mapping[str, Any] | list[dict[str, Any]]
|
|
314
|
+
) -> JsonObject:
|
|
315
|
+
rules = (
|
|
316
|
+
lead_intake_rule.get("rules")
|
|
317
|
+
if isinstance(lead_intake_rule, Mapping)
|
|
318
|
+
else lead_intake_rule
|
|
319
|
+
)
|
|
320
|
+
if not isinstance(rules, list):
|
|
321
|
+
raise ValueError("leadIntakeRule.rules 必须为数组")
|
|
322
|
+
return self.update_campaign(id, {"leadIntakeRule": LeadIntakeRule.rules(*rules)})
|
|
323
|
+
|
|
324
|
+
def reset_campaign_lead_intake_rule(self, id: str) -> JsonObject:
|
|
325
|
+
return self.update_campaign(id, {"leadIntakeRule": None})
|
|
326
|
+
|
|
327
|
+
def delete_campaign(self, id: str) -> None:
|
|
328
|
+
self._request("DELETE", "/campaigns/" + _id(id))
|
|
329
|
+
|
|
330
|
+
def list_scenarios(self, params: Mapping[str, Any] | None = None) -> JsonObject:
|
|
331
|
+
return self._request("GET", "/scenarios", params)
|
|
332
|
+
|
|
333
|
+
def get_scenario(self, id: str) -> JsonObject:
|
|
334
|
+
return self._request("GET", "/scenarios/" + _id(id))
|
|
335
|
+
|
|
336
|
+
def create_scenario(self, name: str, template_alias: str) -> JsonObject:
|
|
337
|
+
return self._request(
|
|
338
|
+
"POST", "/scenarios", body={"name": name, "templateAlias": template_alias}
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def rename_scenario(self, id: str, name: str) -> JsonObject:
|
|
342
|
+
return self._request("PATCH", "/scenarios/" + _id(id), body={"name": name})
|
|
343
|
+
|
|
344
|
+
def delete_scenario(self, id: str) -> None:
|
|
345
|
+
self._request("DELETE", "/scenarios/" + _id(id))
|
|
346
|
+
|
|
347
|
+
def test_dial(
|
|
348
|
+
self,
|
|
349
|
+
phone: str,
|
|
350
|
+
scenario_name: str | None = None,
|
|
351
|
+
scenario_template_alias: str | None = None,
|
|
352
|
+
) -> JsonObject:
|
|
353
|
+
if (scenario_name is None) == (scenario_template_alias is None):
|
|
354
|
+
raise ValueError("scenario_name 与 scenario_template_alias 必须且只能提供一个")
|
|
355
|
+
body = {"phone": phone}
|
|
356
|
+
if scenario_name is not None:
|
|
357
|
+
body["scenarioName"] = scenario_name
|
|
358
|
+
if scenario_template_alias is not None:
|
|
359
|
+
body["scenarioTemplateAlias"] = scenario_template_alias
|
|
360
|
+
return self._request("POST", "/calls/test-dial", body=body)
|
|
361
|
+
|
|
362
|
+
def list_call_records(self, params: Mapping[str, Any] | None = None) -> JsonObject:
|
|
363
|
+
return self._request("GET", "/call-records", params)
|
|
364
|
+
|
|
365
|
+
def get_call_record(self, id: str) -> JsonObject:
|
|
366
|
+
return self._request("GET", "/call-records/" + _id(id))
|
|
367
|
+
|
|
368
|
+
def list_campaign_rounds(
|
|
369
|
+
self, campaign_id: str, params: Mapping[str, Any] | None = None
|
|
370
|
+
) -> JsonObject:
|
|
371
|
+
return self._request("GET", "/campaigns/" + _id(campaign_id) + "/rounds", params)
|
|
372
|
+
|
|
373
|
+
def get_campaign_round(
|
|
374
|
+
self, campaign_id: str, round_id: str, params: Mapping[str, Any] | None = None
|
|
375
|
+
) -> JsonObject:
|
|
376
|
+
return self._request(
|
|
377
|
+
"GET", "/campaigns/" + _id(campaign_id) + "/rounds/" + _id(round_id), params
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def list_devices(self, params: Mapping[str, Any] | None = None) -> JsonObject:
|
|
381
|
+
return self._request("GET", "/devices", params)
|
|
382
|
+
|
|
383
|
+
def get_device(self, id: str) -> JsonObject:
|
|
384
|
+
return self._request("GET", "/devices/" + _id(id))
|
|
385
|
+
|
|
386
|
+
def update_sim_quota(self, device_id: str, sim_id: str, quota: Mapping[str, Any]) -> JsonObject:
|
|
387
|
+
return self._request(
|
|
388
|
+
"PATCH", "/devices/" + _id(device_id) + "/sims/" + _id(sim_id) + "/quota", body=quota
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def update_sim_phone_number(self, device_id: str, sim_id: str, phone_number: str) -> JsonObject:
|
|
392
|
+
if not isinstance(phone_number, str) or (
|
|
393
|
+
phone_number.strip() and not re.fullmatch(r"\+?[0-9]{3,20}", phone_number.strip())
|
|
394
|
+
):
|
|
395
|
+
raise ValueError("SIM 号码必须是3–20位数字(可带+),或空串清除")
|
|
396
|
+
return self._request(
|
|
397
|
+
"PATCH",
|
|
398
|
+
"/devices/" + _id(device_id) + "/sims/" + _id(sim_id) + "/phone",
|
|
399
|
+
body={"phoneNumber": phone_number.strip()},
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def clear_sim_phone_number(self, device_id: str, sim_id: str) -> JsonObject:
|
|
403
|
+
return self.update_sim_phone_number(device_id, sim_id, "")
|
|
404
|
+
|
|
405
|
+
def list_marketing_leads(self, params: Mapping[str, Any] | None = None) -> JsonObject:
|
|
406
|
+
return self._request("GET", "/marketing-leads", params)
|
|
407
|
+
|
|
408
|
+
def get_marketing_lead(self, id: str) -> JsonObject:
|
|
409
|
+
return self._request("GET", "/marketing-leads/" + _id(id))
|
|
410
|
+
|
|
411
|
+
def create_lead_from_call(self, body: Mapping[str, Any]) -> JsonObject:
|
|
412
|
+
return self._request("POST", "/marketing-leads/from-call", body=body, existing_lead=True)
|
|
413
|
+
|
|
414
|
+
def update_marketing_lead(self, id: str, patch: Mapping[str, Any]) -> JsonObject:
|
|
415
|
+
return self._request("PATCH", "/marketing-leads/" + _id(id), body=patch)
|
|
416
|
+
|
|
417
|
+
def delete_marketing_lead(self, id: str) -> None:
|
|
418
|
+
self._request("DELETE", "/marketing-leads/" + _id(id))
|
|
419
|
+
|
|
420
|
+
def get_outbound_daily_stats(self, date: str | None = None) -> JsonObject:
|
|
421
|
+
return self._request("GET", "/stats/outbound-daily", {"date": date})
|
|
422
|
+
|
|
423
|
+
def get_scenario_summary(
|
|
424
|
+
self,
|
|
425
|
+
start_date: str,
|
|
426
|
+
end_date: str,
|
|
427
|
+
*,
|
|
428
|
+
scenario_id: str | None = None,
|
|
429
|
+
scenario_alias: str | None = None,
|
|
430
|
+
) -> JsonObject:
|
|
431
|
+
"""Sum stored daily statistics (inclusive dates; no live recalculation)."""
|
|
432
|
+
return self._request(
|
|
433
|
+
"GET",
|
|
434
|
+
"/stats/scenario-summary",
|
|
435
|
+
{
|
|
436
|
+
"startDate": start_date,
|
|
437
|
+
"endDate": end_date,
|
|
438
|
+
"scenarioId": scenario_id,
|
|
439
|
+
"scenarioAlias": scenario_alias,
|
|
440
|
+
},
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
def fetch_recording(self, recording_url: str) -> bytes:
|
|
444
|
+
response = self._send("GET", _url(recording_url))
|
|
445
|
+
if response.is_error:
|
|
446
|
+
raise self._error(response)
|
|
447
|
+
return response.content
|
|
448
|
+
|
|
449
|
+
def decrypt_pii(self, encoded: str | None) -> str | None:
|
|
450
|
+
if encoded is None:
|
|
451
|
+
return None
|
|
452
|
+
if self._pii is None:
|
|
453
|
+
raise ValueError("未配置 pii_aes_key,无法解密")
|
|
454
|
+
return self._pii.decrypt(encoded)
|
|
455
|
+
|
|
456
|
+
def decrypt_customer(self, customer: Mapping[str, Any]) -> Customer:
|
|
457
|
+
result = deepcopy(dict(customer))
|
|
458
|
+
for field in CUSTOMER_PII_FIELDS:
|
|
459
|
+
if field in result:
|
|
460
|
+
result[field] = self.decrypt_pii(result[field])
|
|
461
|
+
return cast(Customer, result)
|
|
462
|
+
|
|
463
|
+
def decrypt_call_record(self, record: Mapping[str, Any]) -> JsonObject:
|
|
464
|
+
result = deepcopy(dict(record))
|
|
465
|
+
if "phoneNumber" in result:
|
|
466
|
+
result["phoneNumber"] = self.decrypt_pii(result["phoneNumber"])
|
|
467
|
+
return result
|
|
468
|
+
|
|
469
|
+
def decrypt_marketing_lead(self, lead: Mapping[str, Any]) -> JsonObject:
|
|
470
|
+
result = deepcopy(dict(lead))
|
|
471
|
+
if isinstance(result.get("customer"), dict):
|
|
472
|
+
result["customer"] = self.decrypt_customer(result["customer"])
|
|
473
|
+
return result
|
rcs_openapi/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""不在传输异常中暴露凭据、签名 URL 或原始响应正文。"""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RcsOpenApiError(Exception):
|
|
7
|
+
"""服务端业务错误;status/code 可直接用于调用方分支处理。"""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self, code: str, message: str, status: int, *,
|
|
11
|
+
request_id: str | None = None, details: Any = None, retryable: bool | None = None,
|
|
12
|
+
) -> None:
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.code = code
|
|
15
|
+
self.message = message
|
|
16
|
+
self.status = status
|
|
17
|
+
self.request_id = request_id
|
|
18
|
+
self.details = details
|
|
19
|
+
self.retryable = retryable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RcsTransportError(Exception):
|
|
23
|
+
"""连接、TLS 或超时失败;写操作的最终执行状态可能未知。"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RcsProtocolError(Exception):
|
|
27
|
+
"""意外重定向、响应形状或 JSON 不符合接口约定。"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PiiDecryptionError(ValueError):
|
|
31
|
+
"""PII 密文无效、密钥不匹配或认证失败。"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WebhookError(ValueError):
|
|
35
|
+
def __init__(self, code: str, message: str) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.code = code
|
rcs_openapi/pii.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""enc:v1: 的 AES-256-GCM 解密,与 PHP/TypeScript SDK 共用字节格式。"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from cryptography.exceptions import InvalidTag
|
|
11
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
12
|
+
|
|
13
|
+
from .errors import PiiDecryptionError
|
|
14
|
+
|
|
15
|
+
CUSTOMER_PII_FIELDS = ("phoneNumber", "name", "address", "licensePlate")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PiiDecryptor:
|
|
19
|
+
def __init__(self, pii_aes_key: str) -> None:
|
|
20
|
+
self._aes = AESGCM(self.parse_key(pii_aes_key))
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def parse_key(raw: str) -> bytes:
|
|
24
|
+
if not isinstance(raw, str):
|
|
25
|
+
raise ValueError("PII 密钥必须为 hex64 或 base64 字符串")
|
|
26
|
+
text = raw.strip()
|
|
27
|
+
try:
|
|
28
|
+
key = (
|
|
29
|
+
bytes.fromhex(text)
|
|
30
|
+
if re.fullmatch(r"[0-9a-fA-F]{64}", text)
|
|
31
|
+
else base64.b64decode(text, validate=True)
|
|
32
|
+
)
|
|
33
|
+
except (ValueError, binascii.Error):
|
|
34
|
+
raise ValueError("PII 密钥必须为32字节(hex64 或 base64)") from None
|
|
35
|
+
if len(key) != 32:
|
|
36
|
+
raise ValueError("PII 密钥必须为32字节(hex64 或 base64)")
|
|
37
|
+
return key
|
|
38
|
+
|
|
39
|
+
def decrypt(self, encoded: str | None) -> str | None:
|
|
40
|
+
if encoded is None:
|
|
41
|
+
return None
|
|
42
|
+
if not isinstance(encoded, str):
|
|
43
|
+
raise PiiDecryptionError("PII 字段必须是字符串或 None")
|
|
44
|
+
if not encoded.startswith("enc:v1:"):
|
|
45
|
+
return encoded
|
|
46
|
+
try:
|
|
47
|
+
raw = base64.b64decode(encoded[7:], validate=True)
|
|
48
|
+
if len(raw) < 28:
|
|
49
|
+
raise ValueError("short ciphertext")
|
|
50
|
+
return self._aes.decrypt(raw[:12], raw[12:], None).decode("utf-8")
|
|
51
|
+
except (ValueError, binascii.Error, InvalidTag, UnicodeError):
|
|
52
|
+
raise PiiDecryptionError("PII 解密失败:密文无效或密钥不匹配") from None
|
|
53
|
+
|
|
54
|
+
def decrypt_fields(self, obj: Mapping[str, Any], fields: Sequence[str]) -> dict[str, Any]:
|
|
55
|
+
result = deepcopy(dict(obj))
|
|
56
|
+
for field in fields:
|
|
57
|
+
if field in result:
|
|
58
|
+
result[field] = self.decrypt(result[field])
|
|
59
|
+
return result
|
rcs_openapi/py.typed
ADDED
|
File without changes
|
rcs_openapi/rules.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""线索纳入规则构造器;fieldKey 是否存在仍由服务端按场景校验。"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LeadIntakeRule:
|
|
9
|
+
OUTCOMES = ("success", "failure", "pending", "no_effective_communication")
|
|
10
|
+
CONDITION_MODES = ("none", "or", "and")
|
|
11
|
+
BUILTIN_FIELDS = ("actionResult", "followup", "summary")
|
|
12
|
+
BUILTIN_OPERATORS = ("includes", "excludes")
|
|
13
|
+
EXTRACT_OPERATORS = ("includes", "excludes", "equals", "notEquals")
|
|
14
|
+
MAX_RULES = 50
|
|
15
|
+
MAX_CONDITIONS = 20
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def rules(cls, *rules: Mapping[str, Any]) -> dict[str, Any]:
|
|
19
|
+
if not 1 <= len(rules) <= cls.MAX_RULES:
|
|
20
|
+
raise ValueError("leadIntakeRule 必须有 1–50 条规则;恢复默认请用 reset 方法")
|
|
21
|
+
for rule in rules:
|
|
22
|
+
cls._validate_rule(rule)
|
|
23
|
+
return {"rules": deepcopy([dict(rule) for rule in rules])}
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def rule(
|
|
27
|
+
cls,
|
|
28
|
+
outcome: str,
|
|
29
|
+
condition_mode: str = "none",
|
|
30
|
+
conditions: list[dict[str, Any]] | None = None,
|
|
31
|
+
enabled: bool = True,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
result = {
|
|
34
|
+
"enabled": enabled,
|
|
35
|
+
"outcome": outcome,
|
|
36
|
+
"conditionMode": condition_mode,
|
|
37
|
+
"conditions": [] if conditions is None else deepcopy(conditions),
|
|
38
|
+
}
|
|
39
|
+
cls._validate_rule(result)
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def builtin(cls, field: str, operator: str, keyword: str) -> dict[str, Any]:
|
|
44
|
+
condition = {"source": "builtin", "field": field, "operator": operator, "keyword": keyword}
|
|
45
|
+
cls._validate_condition(condition)
|
|
46
|
+
return condition
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def extract(cls, field_key: str, operator: str, keyword: str) -> dict[str, Any]:
|
|
50
|
+
condition = {
|
|
51
|
+
"source": "extract",
|
|
52
|
+
"fieldKey": field_key,
|
|
53
|
+
"operator": operator,
|
|
54
|
+
"keyword": keyword,
|
|
55
|
+
}
|
|
56
|
+
cls._validate_condition(condition)
|
|
57
|
+
return condition
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def _validate_rule(cls, rule: Mapping[str, Any]) -> None:
|
|
61
|
+
if not isinstance(rule, Mapping) or rule.get("outcome") not in cls.OUTCOMES:
|
|
62
|
+
raise ValueError("规则 outcome 取值非法")
|
|
63
|
+
mode = rule.get("conditionMode", "none")
|
|
64
|
+
if mode not in cls.CONDITION_MODES:
|
|
65
|
+
raise ValueError("规则 conditionMode 取值非法")
|
|
66
|
+
conditions = rule.get("conditions", [])
|
|
67
|
+
if not isinstance(conditions, list) or len(conditions) > cls.MAX_CONDITIONS:
|
|
68
|
+
raise ValueError("conditions 必须是最多20项的数组")
|
|
69
|
+
if (mode == "none" and conditions) or (mode != "none" and not conditions):
|
|
70
|
+
raise ValueError("none 不得带条件;or/and 至少一个条件")
|
|
71
|
+
if "enabled" in rule and not isinstance(rule["enabled"], bool):
|
|
72
|
+
raise ValueError("enabled 必须是布尔值")
|
|
73
|
+
for condition in conditions:
|
|
74
|
+
cls._validate_condition(condition)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def _validate_condition(cls, condition: Mapping[str, Any]) -> None:
|
|
78
|
+
if not isinstance(condition, Mapping):
|
|
79
|
+
raise ValueError("条件必须为对象")
|
|
80
|
+
keyword = condition.get("keyword")
|
|
81
|
+
if not isinstance(keyword, str) or not keyword.replace(",", "").strip():
|
|
82
|
+
raise ValueError("keyword 必须包含非空关键词")
|
|
83
|
+
source = condition.get("source", "builtin")
|
|
84
|
+
if source == "builtin":
|
|
85
|
+
if (
|
|
86
|
+
condition.get("field") not in cls.BUILTIN_FIELDS
|
|
87
|
+
or condition.get("operator") not in cls.BUILTIN_OPERATORS
|
|
88
|
+
):
|
|
89
|
+
raise ValueError("内置条件 field/operator 非法")
|
|
90
|
+
elif source == "extract":
|
|
91
|
+
key = condition.get("fieldKey")
|
|
92
|
+
if (
|
|
93
|
+
not isinstance(key, str)
|
|
94
|
+
or not key.strip()
|
|
95
|
+
or condition.get("operator") not in cls.EXTRACT_OPERATORS
|
|
96
|
+
):
|
|
97
|
+
raise ValueError("提取条件 fieldKey/operator 非法")
|
|
98
|
+
else:
|
|
99
|
+
raise ValueError("条件 source 非法")
|
rcs_openapi/types.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""JSON 字段采用服务端 camelCase;未知响应字段仍原样保留。"""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Generic, NotRequired, TypeAlias, TypedDict, TypeVar
|
|
4
|
+
|
|
5
|
+
JsonObject: TypeAlias = dict[str, Any]
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ListResponse(TypedDict, Generic[T]):
|
|
10
|
+
items: list[T]
|
|
11
|
+
total: int
|
|
12
|
+
page: int
|
|
13
|
+
pageSize: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CustomField(TypedDict):
|
|
17
|
+
fieldKey: str
|
|
18
|
+
label: str
|
|
19
|
+
value: Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PartnerBinding(TypedDict):
|
|
23
|
+
id: str
|
|
24
|
+
partnerId: str
|
|
25
|
+
externalMerchantId: str
|
|
26
|
+
externalUserId: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CurrentUser(TypedDict):
|
|
30
|
+
id: str
|
|
31
|
+
username: str | None
|
|
32
|
+
phone: str | None
|
|
33
|
+
phoneVerified: bool
|
|
34
|
+
name: str
|
|
35
|
+
email: str | None
|
|
36
|
+
company: str | None
|
|
37
|
+
partnerBinding: PartnerBinding | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Customer(TypedDict):
|
|
41
|
+
id: str
|
|
42
|
+
phoneNumber: str
|
|
43
|
+
name: str
|
|
44
|
+
status: str
|
|
45
|
+
userId: NotRequired[str | None]
|
|
46
|
+
age: NotRequired[str | None]
|
|
47
|
+
title: NotRequired[str | None]
|
|
48
|
+
purchasedServices: NotRequired[str | None]
|
|
49
|
+
notes: NotRequired[str | None]
|
|
50
|
+
gender: NotRequired[str | None]
|
|
51
|
+
source: NotRequired[str | None]
|
|
52
|
+
intentLevel: NotRequired[str | None]
|
|
53
|
+
totalCalls: NotRequired[int]
|
|
54
|
+
lastContactAt: NotRequired[str | None]
|
|
55
|
+
createdAt: NotRequired[str]
|
|
56
|
+
updatedAt: NotRequired[str]
|
|
57
|
+
externalData: NotRequired[dict[str, Any] | None]
|
|
58
|
+
debug_info: NotRequired[dict[str, str]]
|
|
59
|
+
dndSetAt: NotRequired[str | None]
|
|
60
|
+
address: NotRequired[str | None]
|
|
61
|
+
licensePlate: NotRequired[str | None]
|
|
62
|
+
tags: NotRequired[list[str]]
|
|
63
|
+
customFields: NotRequired[list[CustomField]]
|
|
64
|
+
doNotDisturb: NotRequired[bool]
|
|
65
|
+
dndReason: NotRequired[str | None]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CustomerInput(TypedDict, total=False):
|
|
69
|
+
phoneNumber: str
|
|
70
|
+
name: str
|
|
71
|
+
title: str | None
|
|
72
|
+
purchasedServices: str | None
|
|
73
|
+
notes: str | None
|
|
74
|
+
gender: str | None
|
|
75
|
+
age: int | None
|
|
76
|
+
address: str | None
|
|
77
|
+
licensePlate: str | None
|
|
78
|
+
source: str | None
|
|
79
|
+
tags: list[str]
|
|
80
|
+
customFields: dict[str, Any]
|
|
81
|
+
doNotDisturb: bool
|
|
82
|
+
dndReason: str | None
|
rcs_openapi/webhook.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""框架无关的原始请求体验签;持久化事件 ID 去重由接收方负责。"""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .errors import WebhookError
|
|
14
|
+
from .pii import CUSTOMER_PII_FIELDS, PiiDecryptor
|
|
15
|
+
from .types import JsonObject
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WebhookEvent:
|
|
19
|
+
def __init__(self, envelope: Mapping[str, Any], pii: PiiDecryptor | None = None) -> None:
|
|
20
|
+
self._envelope = deepcopy(dict(envelope))
|
|
21
|
+
self._pii = pii
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def envelope(self) -> JsonObject:
|
|
25
|
+
return deepcopy(self._envelope)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def id(self) -> str:
|
|
29
|
+
return str(self._envelope["id"])
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def type(self) -> str:
|
|
33
|
+
return str(self._envelope["type"])
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def api_version(self) -> str:
|
|
37
|
+
return str(self._envelope.get("apiVersion", ""))
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def occurred_at(self) -> str:
|
|
41
|
+
return str(self._envelope.get("occurredAt", ""))
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def user_id(self) -> str:
|
|
45
|
+
account = self._envelope.get("account")
|
|
46
|
+
return str(account.get("userId", "")) if isinstance(account, dict) else ""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def data(self) -> JsonObject:
|
|
50
|
+
data = self._envelope.get("data")
|
|
51
|
+
return deepcopy(data) if isinstance(data, dict) else {}
|
|
52
|
+
|
|
53
|
+
def is_outbound_call_finished(self) -> bool:
|
|
54
|
+
return self.type == "outbound_call.finished"
|
|
55
|
+
|
|
56
|
+
def outbound_call_task(self) -> JsonObject | None:
|
|
57
|
+
"""结束任务快照;数据仍遵循服务端当前权限与 PII 加密规则。"""
|
|
58
|
+
task = self.data.get("task")
|
|
59
|
+
return task if self.is_outbound_call_finished() and isinstance(task, dict) else None
|
|
60
|
+
|
|
61
|
+
def is_lead_created(self) -> bool:
|
|
62
|
+
return self.type == "lead.created"
|
|
63
|
+
|
|
64
|
+
def is_device_offline(self) -> bool:
|
|
65
|
+
return self.type == "device.offline"
|
|
66
|
+
|
|
67
|
+
def is_device_alarm(self) -> bool:
|
|
68
|
+
return self.type == "device.alarm"
|
|
69
|
+
|
|
70
|
+
def is_sim_registered(self) -> bool:
|
|
71
|
+
return self.type == "sim.registered"
|
|
72
|
+
|
|
73
|
+
def lead(self) -> JsonObject | None:
|
|
74
|
+
lead = self.data.get("lead")
|
|
75
|
+
return lead if self.is_lead_created() and isinstance(lead, dict) else None
|
|
76
|
+
|
|
77
|
+
def decrypted_lead(self) -> JsonObject | None:
|
|
78
|
+
lead = self.lead()
|
|
79
|
+
if lead is not None and isinstance(lead.get("customer"), dict):
|
|
80
|
+
if self._pii is None:
|
|
81
|
+
raise ValueError("未配置 pii_aes_key,无法解密线索 PII")
|
|
82
|
+
lead["customer"] = self._pii.decrypt_fields(lead["customer"], CUSTOMER_PII_FIELDS)
|
|
83
|
+
return lead
|
|
84
|
+
|
|
85
|
+
def device(self) -> JsonObject | None:
|
|
86
|
+
device = self.data.get("device")
|
|
87
|
+
return (
|
|
88
|
+
device
|
|
89
|
+
if self.type in ("device.offline", "device.alarm", "sim.registered")
|
|
90
|
+
and isinstance(device, dict)
|
|
91
|
+
else None
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def sim_registered(self) -> JsonObject | None:
|
|
95
|
+
if not self.is_sim_registered():
|
|
96
|
+
return None
|
|
97
|
+
data = self.data
|
|
98
|
+
sim = data.get("sim")
|
|
99
|
+
return {
|
|
100
|
+
"device": self.device(),
|
|
101
|
+
"sim": sim if isinstance(sim, dict) else None,
|
|
102
|
+
"registeredAt": data.get("registeredAt"),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def offline(self) -> JsonObject | None:
|
|
106
|
+
if not self.is_device_offline():
|
|
107
|
+
return None
|
|
108
|
+
data = self.data
|
|
109
|
+
return {
|
|
110
|
+
"device": self.device(),
|
|
111
|
+
"offlineAt": data.get("offlineAt"),
|
|
112
|
+
"reason": data.get("reason"),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
def alarm(self) -> JsonObject | None:
|
|
116
|
+
if not self.is_device_alarm():
|
|
117
|
+
return None
|
|
118
|
+
alarm = self.data.get("alarm")
|
|
119
|
+
return alarm if isinstance(alarm, dict) else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class WebhookReceiver:
|
|
123
|
+
def __init__(
|
|
124
|
+
self, secret: str, *, pii_aes_key: str | None = None, tolerance_seconds: int = 300
|
|
125
|
+
) -> None:
|
|
126
|
+
if not isinstance(secret, str) or not secret:
|
|
127
|
+
raise ValueError("缺少 webhook secret")
|
|
128
|
+
if (
|
|
129
|
+
isinstance(tolerance_seconds, bool)
|
|
130
|
+
or not isinstance(tolerance_seconds, int)
|
|
131
|
+
or tolerance_seconds < 0
|
|
132
|
+
):
|
|
133
|
+
raise ValueError("tolerance_seconds 必须为非负整数")
|
|
134
|
+
self._secret = secret.encode("utf-8")
|
|
135
|
+
self._tolerance = tolerance_seconds
|
|
136
|
+
self._pii = PiiDecryptor(pii_aes_key) if pii_aes_key is not None else None
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _raw(raw_body: bytes | str) -> bytes:
|
|
140
|
+
if isinstance(raw_body, str):
|
|
141
|
+
return raw_body.encode("utf-8")
|
|
142
|
+
if not isinstance(raw_body, bytes):
|
|
143
|
+
raise WebhookError("invalid_body", "需要原始请求体 bytes 或 str")
|
|
144
|
+
return raw_body
|
|
145
|
+
|
|
146
|
+
def verify_signature(
|
|
147
|
+
self, raw_body: bytes | str, timestamp: str, signature_header: str
|
|
148
|
+
) -> bool:
|
|
149
|
+
signature = signature_header.removeprefix("v1=").strip()
|
|
150
|
+
if not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
|
|
151
|
+
return False
|
|
152
|
+
expected = hmac.new(
|
|
153
|
+
self._secret, timestamp.encode("utf-8") + b"." + self._raw(raw_body), hashlib.sha256
|
|
154
|
+
).hexdigest()
|
|
155
|
+
return hmac.compare_digest(expected, signature.lower())
|
|
156
|
+
|
|
157
|
+
def check_timestamp(self, timestamp: str, now: int | float | None = None) -> bool:
|
|
158
|
+
if not re.fullmatch(r"[0-9]{1,20}", timestamp):
|
|
159
|
+
return False
|
|
160
|
+
return abs((time.time() if now is None else now) - int(timestamp)) <= self._tolerance
|
|
161
|
+
|
|
162
|
+
def parse(
|
|
163
|
+
self,
|
|
164
|
+
raw_body: bytes | str,
|
|
165
|
+
headers: Mapping[str, str | Sequence[str]],
|
|
166
|
+
*,
|
|
167
|
+
now: int | float | None = None,
|
|
168
|
+
) -> WebhookEvent:
|
|
169
|
+
normalized: dict[str, str] = {}
|
|
170
|
+
for key, value in headers.items():
|
|
171
|
+
name = key.lower()
|
|
172
|
+
if name not in ("x-rcs-webhook-timestamp", "x-rcs-webhook-signature"):
|
|
173
|
+
continue
|
|
174
|
+
if name in normalized:
|
|
175
|
+
raise WebhookError("invalid_header", "重复的验签请求头")
|
|
176
|
+
if not isinstance(value, str):
|
|
177
|
+
if len(value) != 1 or not isinstance(value[0], str):
|
|
178
|
+
raise WebhookError("invalid_header", "验签请求头必须具有唯一值")
|
|
179
|
+
value = value[0]
|
|
180
|
+
normalized[name] = value
|
|
181
|
+
timestamp = normalized.get("x-rcs-webhook-timestamp", "")
|
|
182
|
+
signature = normalized.get("x-rcs-webhook-signature", "")
|
|
183
|
+
if not timestamp or not signature:
|
|
184
|
+
raise WebhookError("missing_header", "缺少 Webhook 时间戳或签名头")
|
|
185
|
+
raw = self._raw(raw_body)
|
|
186
|
+
if not self.verify_signature(raw, timestamp, signature):
|
|
187
|
+
raise WebhookError("bad_signature", "Webhook 签名校验失败")
|
|
188
|
+
if not self.check_timestamp(timestamp, now):
|
|
189
|
+
raise WebhookError("stale_timestamp", "Webhook 时间戳超出容忍窗口")
|
|
190
|
+
try:
|
|
191
|
+
envelope = json.loads(raw)
|
|
192
|
+
except (ValueError, UnicodeError):
|
|
193
|
+
raise WebhookError("invalid_body", "Webhook 请求体不是有效 JSON") from None
|
|
194
|
+
if not isinstance(envelope, dict) or any(
|
|
195
|
+
not isinstance(envelope.get(field), str) or not envelope[field]
|
|
196
|
+
for field in ("id", "type")
|
|
197
|
+
):
|
|
198
|
+
raise WebhookError("invalid_body", "Webhook 信封缺少有效 id/type")
|
|
199
|
+
return WebhookEvent(envelope, self._pii)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def parse_partner_login(
|
|
203
|
+
self, raw_body: bytes | str, headers: Mapping[str, str], *, now: int | float | None = None
|
|
204
|
+
) -> JsonObject:
|
|
205
|
+
"""验证独立的合作方登录结果信封。按 eventId 去重由调用方负责。"""
|
|
206
|
+
normalized = {name.lower(): value for name, value in headers.items()}
|
|
207
|
+
timestamp = normalized.get("x-rcs-webhook-timestamp", "")
|
|
208
|
+
signature = normalized.get("x-rcs-webhook-signature", "")
|
|
209
|
+
event_id = normalized.get("x-rcs-webhook-id", "")
|
|
210
|
+
raw = self._raw(raw_body)
|
|
211
|
+
if (
|
|
212
|
+
not re.fullmatch(r"[0-9]{10}", timestamp)
|
|
213
|
+
or not re.fullmatch(r"v1=[0-9a-fA-F]{64}", signature)
|
|
214
|
+
or not self.check_timestamp(timestamp, now)
|
|
215
|
+
or not self.verify_signature(raw, timestamp, signature)
|
|
216
|
+
):
|
|
217
|
+
raise WebhookError("bad_signature", "合作方登录回调验签失败")
|
|
218
|
+
try:
|
|
219
|
+
event = json.loads(raw)
|
|
220
|
+
except (ValueError, UnicodeError):
|
|
221
|
+
raise WebhookError("invalid_body", "合作方登录回调不是有效 JSON") from None
|
|
222
|
+
if (
|
|
223
|
+
not isinstance(event, dict) or event.get("event") != "open_api.login.result"
|
|
224
|
+
or type(event.get("version")) is not int or event["version"] != 1
|
|
225
|
+
or not event_id or event.get("eventId") != event_id or event.get("requestId") != event_id
|
|
226
|
+
or not isinstance(event.get("partner_id"), str) or not event["partner_id"]
|
|
227
|
+
):
|
|
228
|
+
raise WebhookError("invalid_body", "合作方登录回调信封无效")
|
|
229
|
+
status = event.get("status")
|
|
230
|
+
error = event.get("error")
|
|
231
|
+
if status == "succeeded" and isinstance(event.get("code"), str) and event["code"]:
|
|
232
|
+
expires_at = event.get("expiresAt")
|
|
233
|
+
if isinstance(expires_at, str):
|
|
234
|
+
try:
|
|
235
|
+
datetime.fromisoformat(expires_at)
|
|
236
|
+
return event
|
|
237
|
+
except ValueError:
|
|
238
|
+
pass
|
|
239
|
+
if status == "failed" and isinstance(error, dict) and error.get("code") == "INVALID_LOGIN":
|
|
240
|
+
return event
|
|
241
|
+
raise WebhookError("invalid_body", "合作方登录回调结果无效")
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rcs-openapi-client
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: RCS 开放 API Python SDK:AK/SK、客户读写、业务接口、PII 解密及 Webhook 验签
|
|
5
|
+
Author: Akimimi
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Project-URL: Repository, https://gitee.com/akimimi/rcs-openapi-python-client
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: httpx<1,>=0.27
|
|
12
|
+
Requires-Dist: cryptography>=43
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
15
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
16
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
17
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
18
|
+
Requires-Dist: ruff>=0.9; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# RCS Open API Python SDK
|
|
22
|
+
|
|
23
|
+
Python 3.11+ 的同步客户端,面向 RCS 已上线开放 API。覆盖 PHP SDK 1.11.0 的全部公开能力,并提供客户字段定义读取、类型提示、PII 解密和 Webhook 验签。
|
|
24
|
+
|
|
25
|
+
生产 API 基址为 `https://vas-api.akimimi.com`。`base_url` 只填根地址,SDK 自动追加 `/api/open/v1`。调用范围由 AK/SK 绑定的普通用户账号决定;IP 白名单由服务端管理员配置。
|
|
26
|
+
|
|
27
|
+
## 安装
|
|
28
|
+
|
|
29
|
+
包名 `rcs-openapi-client`,导入名 `rcs_openapi`,当前版本 `0.4.0`。通过私有 Gitee 发行版和构建包分发。
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# 安装取得的 wheel
|
|
33
|
+
python -m pip install ./rcs_openapi_client-0.4.0-py3-none-any.whl
|
|
34
|
+
|
|
35
|
+
# 或安装私有仓库的固定版本(需先配置自己的 SSH 访问权限)
|
|
36
|
+
python -m pip install 'git+ssh://git@gitee.com/akimimi/rcs-openapi-python-client.git@v0.4.0'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
开发分支示例可用 `@feature/python-sdk`,生产请固定审核通过的提交或发布版本,避免跟随可变分支。依赖为 HTTPX 与 cryptography,由 pip 自动安装。默认遵守 HTTPX 代理和 CA 环境设置;若使用 SOCKS 代理,另外安装 `httpx[socks]`。SDK 保持 TLS 验证开启,不提供跳过证书校验的快捷开关。
|
|
40
|
+
|
|
41
|
+
## 获取 token、创建和编辑客户
|
|
42
|
+
|
|
43
|
+
管理员发放两类不同用途的密钥:AK/SK 用来换取 72 小时 Bearer token,PII AES 密钥用来在本地解密响应中的姓名、手机号、地址和车牌。PII 密钥接受32字节的 hex64 或 base64 字符串。
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import os
|
|
47
|
+
from rcs_openapi import RcsOpenApiClient
|
|
48
|
+
|
|
49
|
+
with RcsOpenApiClient(
|
|
50
|
+
base_url="https://vas-api.akimimi.com",
|
|
51
|
+
access_key_id=os.environ["RCS_ACCESS_KEY_ID"],
|
|
52
|
+
secret_key=os.environ["RCS_SECRET_KEY"],
|
|
53
|
+
pii_aes_key=os.environ["RCS_PII_AES_KEY"],
|
|
54
|
+
timeout=15,
|
|
55
|
+
) as client:
|
|
56
|
+
client.authenticate() # 缓存在当前实例中;不要写入日志
|
|
57
|
+
customer = client.create_customer({
|
|
58
|
+
"phoneNumber": "13800000000",
|
|
59
|
+
"name": "张三",
|
|
60
|
+
"age": 35,
|
|
61
|
+
"licensePlate": "沪A12345",
|
|
62
|
+
"tags": ["老客户"],
|
|
63
|
+
})
|
|
64
|
+
# customer 的 PII 字段仍是 enc:v1: 密文;原响应不会被解密函数修改。
|
|
65
|
+
plain = client.decrypt_customer(customer)
|
|
66
|
+
updated = client.update_customer(customer["id"], {
|
|
67
|
+
"notes": "资料已更新",
|
|
68
|
+
"licensePlate": None,
|
|
69
|
+
"tags": [],
|
|
70
|
+
"doNotDisturb": True,
|
|
71
|
+
"dndReason": "客户要求停止联系",
|
|
72
|
+
})
|
|
73
|
+
detail = client.get_customer(customer["id"])
|
|
74
|
+
page = client.list_customers({"q": "13800000000", "page": 1, "pageSize": 20})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
本段示例会真实创建、编辑客户;请换成已获授权的业务数据。完整脚本见 [examples/customer_flow.py](examples/customer_flow.py),不会打印密钥或 token。
|
|
78
|
+
|
|
79
|
+
- SDK 方法使用 snake_case;请求/响应 JSON 字段仍为服务端 camelCase。
|
|
80
|
+
- 创建时 `phoneNumber`、`name` 必填;客户归属由凭据决定,不能伪造 `tenantId`/`userId`。
|
|
81
|
+
- PATCH 仅修改提供的字段;`None` 清空可空字段,`tags=[]` 清空标签,未提供字段保持不变。客户 `age` 输入是整数或 `None`,响应按服务端历史格式为字符串或 `None`。
|
|
82
|
+
- 客户创建与编辑均支持 `customFields`。先用 `list_customer_fields()` 获取定义;键可用中文 label 或 fieldKey,值按定义类型传入,`None` 清空单项。日期通常输入 `YYYY-MM-DD`,返回值按服务端归一化为 ISO 字符串。
|
|
83
|
+
- 列表方法返回服务端分页对象;SDK 不自动遍历所有页。查询布尔值会正确编码为 `true/false`,保留 `False` 和 `0`,忽略 `None` 和空字符串。
|
|
84
|
+
- 可直接传 `access_token=...` 或调用 `set_access_token(...)`;token 到期后再次 `authenticate()`。SDK 不会自动重放写请求。
|
|
85
|
+
- 使用 `with` 管理 HTTP 连接;长期客户端在服务关闭时调用 `close()`。
|
|
86
|
+
|
|
87
|
+
## 其它已上线接口
|
|
88
|
+
|
|
89
|
+
[完整 PHP/Python 方法对照](docs/api-parity.md) 包含主体、营销计划及轮次、场景、通话记录、设备与 SIM 配额/号码、营销线索、账号日统计、测试拨号和录音下载。返回 JSON 对象保留服务端字段,常用客户结构提供 `Customer`、`CustomerInput`、`ListResponse` 类型。
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from rcs_openapi import LeadIntakeRule
|
|
93
|
+
|
|
94
|
+
rule = LeadIntakeRule.rules(
|
|
95
|
+
LeadIntakeRule.rule("success"),
|
|
96
|
+
LeadIntakeRule.rule("pending", "or", [
|
|
97
|
+
LeadIntakeRule.builtin("followup", "includes", "再联系,考虑"),
|
|
98
|
+
LeadIntakeRule.extract("意向车型", "notEquals", "无"),
|
|
99
|
+
]),
|
|
100
|
+
)
|
|
101
|
+
# client.set_campaign_lead_intake_rule(campaign_id, rule)
|
|
102
|
+
# client.reset_campaign_lead_intake_rule(campaign_id) # 恢复系统默认
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`create_campaign` 创建草稿;`set_campaign_status(id, "active")` 可能开始业务执行。`test_dial` 会实际下发拨号,必须由调用方明确发起;SDK 的自动测试不会拨号。
|
|
106
|
+
|
|
107
|
+
`create_lead_from_call` 收到带 `leadId` 与 `alreadyExisted=true` 的409响应时正常返回,方便幂等处理;其它409仍抛异常。删除计划/场景/线索成功返回 `None`。`fetch_recording(recording_url)` 返回音频 `bytes`,使用录音 URL 自带的签名,不附加 API Bearer token,不跟随重定向。`decrypt_call_record`、`decrypt_marketing_lead` 提供相应 PII 解密。
|
|
108
|
+
|
|
109
|
+
## 错误与超时
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from rcs_openapi import RcsOpenApiError, RcsTransportError, RcsProtocolError
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
customer = client.get_customer(customer_id)
|
|
116
|
+
except RcsOpenApiError as error:
|
|
117
|
+
# error.status: 401/403/404/409/422/429/500 等;error.code 为服务端错误码
|
|
118
|
+
if error.status == 404:
|
|
119
|
+
pass # 不存在或不在当前账号可见范围
|
|
120
|
+
else:
|
|
121
|
+
raise
|
|
122
|
+
except RcsTransportError:
|
|
123
|
+
raise # 连接/TLS/超时;创建可能已经成功,先按手机号查询确认结果
|
|
124
|
+
except RcsProtocolError:
|
|
125
|
+
raise # 无效 JSON/响应结构或意外重定向
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
不自动重试创建、编辑、删除或拨号;网络异常不会把密钥、token 或签名 URL 写入 SDK 异常消息。服务端业务错误保留其原始 message,调用方应按自己的日志脱敏规则处理。
|
|
129
|
+
|
|
130
|
+
## Webhook 验签
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from rcs_openapi import WebhookReceiver, WebhookError
|
|
134
|
+
|
|
135
|
+
receiver = WebhookReceiver(
|
|
136
|
+
webhook_secret, # 端点独立签名密钥,不能拿 AK/SK 或 PII 密钥代替
|
|
137
|
+
pii_aes_key=pii_aes_key,
|
|
138
|
+
tolerance_seconds=300,
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
event = receiver.parse(raw_request_body_bytes, request_headers)
|
|
142
|
+
except WebhookError as error:
|
|
143
|
+
# error.code: missing_header / invalid_header / bad_signature /
|
|
144
|
+
# stale_timestamp / invalid_body;应拒绝处理
|
|
145
|
+
raise
|
|
146
|
+
|
|
147
|
+
# 持久化检查 event.id 是否已处理;重复事件直接返回2xx。
|
|
148
|
+
# 时间窗口不能替代按事件ID去重。
|
|
149
|
+
if event.is_lead_created():
|
|
150
|
+
lead = event.decrypted_lead()
|
|
151
|
+
elif event.is_device_alarm():
|
|
152
|
+
alarm = event.alarm()
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
验签必须使用**原始请求体字节**,不要先 JSON 解析再序列化。支持请求头大小写差异、HMAC-SHA256 常量时间比较、默认 ±300 秒时间窗口;事件属性包括 `id`、`type`、`api_version`、`occurred_at`、`user_id`、`data` 和 `envelope`。四类事件的便捷方法为 `lead()`/`decrypted_lead()`、`device()`/`offline()`/`alarm()`/`sim_registered()`,未知事件仍可读取原始 data。
|
|
156
|
+
|
|
157
|
+
接收方负责持久化去重和业务任务投递,应快速返回2xx。SDK 不启动服务器,不强制绑定 Web 框架。[FastAPI 适配示例](examples/webhook_fastapi.py) 演示原始请求体验签,FastAPI 由调用方自行安装。
|
|
158
|
+
|
|
159
|
+
## 开发与验证
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
python3.11 -m venv .venv
|
|
163
|
+
. .venv/bin/activate
|
|
164
|
+
python -m pip install -e '.[dev]'
|
|
165
|
+
pytest -q
|
|
166
|
+
ruff check src tests scripts examples
|
|
167
|
+
ruff format --check src tests scripts examples
|
|
168
|
+
mypy src
|
|
169
|
+
python -m build
|
|
170
|
+
python -m twine check dist/*
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
[验收记录](docs/validation.md) 区分协议单测、跨语言字节向量、真实 HTTP/PostgreSQL 和 wheel 安装结果。隔离验收脚本只接受 `127.0.0.1/localhost/::1:3033`,通过标准输入传入本轮凭据,禁止把生产凭据写进测试文件。SDK 本身仍可正常连接上述生产 HTTPS 域名。
|
|
174
|
+
|
|
175
|
+
本软件为专有软件,使用与分发需获得授权。
|
|
176
|
+
|
|
177
|
+
## 场景日汇总
|
|
178
|
+
|
|
179
|
+
需已部署 `GET /stats/scenario-summary` 的服务端;测试基址为 `https://rcs-api.mimixiche.cn`,SDK 更新不代表生产接口已发布。
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
totals = client.get_scenario_summary(
|
|
183
|
+
'2026-10-01', '2026-10-31', scenario_alias='insurance',
|
|
184
|
+
)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
必填日期包含首尾,相同日期统计一天;可选 `scenario_id` / `scenario_alias` 同时提供时取交集。返回 outboundCount、connectedCount、totalDurationSeconds(秒)、successCount、leadCount、outboundCustomerCount 六项累加值。仅查询已落库日统计,无匹配返回六项零,不补算当天/缺失日,客户数不跨日期/场景再次去重。服务端错误沿用 RcsOpenApiError,例如422不会转换成零。
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
## 接口外呼任务(0.2.0 私有分发)
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
request = {"campaignId": "cmp_xxx", "scenarioId": "scn_xxx", "phone": "13800138000", "externalRef": "order-001", "customer": {"name": "张三"}}
|
|
194
|
+
task = client.create_outbound_call(request)
|
|
195
|
+
current = client.get_outbound_call_task(task["taskId"])
|
|
196
|
+
page = client.list_outbound_calls({"externalRef": request["externalRef"], "status": "queued"})
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
沿用后台预建并发布的接口计划,202是可靠受理,不表示已拨号。externalRef必传、用户内唯一;相同归一化请求重放200、内容变化409 external_ref_conflict,只有成功接单占号。SDK不自动重试写入、不生成或替换业务单号、不自动重拨。超时结果未知时由调用方沿用原单号和原请求重试,task_in_flight应查询已存在taskId,不能换号绕过。
|
|
200
|
+
|
|
201
|
+
RcsOpenApiError保留code/message/status,并新增request_id、details;旧三参构造兼容。完整39项业务码由服务接入文档定义,典型包括external_ref_conflict、task_in_flight、missing_required_field、feature_disabled、no_available_window和payload_too_large。服务端可能带字段校验和已有taskId详情,调用方可按编码分支处理。
|
|
202
|
+
|
|
203
|
+
WebhookEvent新增is_outbound_call_finished()/outbound_call_task(),沿用WebhookReceiver的真实原始体验签。事件ID是outbound_call.finished:<taskId>,接收方负责持久化去重;任务快照保持密文PII及当前权限,不触发自动解密或重拨。0.2.0只提供私有wheel/sdist,不发布公共PyPI;包发布不代表目标API已部署。
|
|
204
|
+
|
|
205
|
+
### 合作方 API 登录(0.3.0)
|
|
206
|
+
|
|
207
|
+
平台管理员先开通 API 登录,登记 HTTPS 回调及出口 IP 白名单。合作方后端通过
|
|
208
|
+
`RcsOpenApiClient(base_url, partner_id=..., partner_client_id=..., partner_client_secret=...)` 配置凭证。
|
|
209
|
+
调用 `send_partner_login_sms(phone)` 获取挑战,再以 `submit_partner_login(input, idempotency_key)`
|
|
210
|
+
提交短信字段或 `method=password` 的密码字段。回调处理器使用
|
|
211
|
+
`WebhookReceiver(signing_key).parse_partner_login(raw_body, headers)`,核对合作方和请求 ID,
|
|
212
|
+
按事件 ID 去重。成功时将兑换码传给 `exchange_partner_login_code(code)`,按用户保存返回的访问令牌。
|
|
213
|
+
同一提交重试复用幂等键,兑换不自动重试。密码方式需单独启用。
|
|
214
|
+
|
|
215
|
+
### 主体管理与营销结果日统计
|
|
216
|
+
|
|
217
|
+
使用 create_agent(input) 创建主体,必填 aliasName、storeAddress、entityType 和 name,可选 openTime、closeTime、storePhone。delete_agent(id) 归档主体,未结束计划仍引用时抛出 409 conflict。get_outbound_daily_stats(date) 的 marketingOutcomeCounts 返回 success、failure、no_effective_communication。
|
|
218
|
+
|
|
219
|
+
### 当前用户资料
|
|
220
|
+
|
|
221
|
+
使用已认证的客户端调用 `get_current_user()`,返回当前账号资料及适用的合作方绑定。
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
rcs_openapi/__init__.py,sha256=bhyQ5tkz3oROiHdY9d6yBch56qVQvG4S8J0cQDdc6Kg,861
|
|
2
|
+
rcs_openapi/client.py,sha256=JnjYQULD5gaP7RnVgIG5mxz0YkEEm8LzXU-0VEcgSuA,19452
|
|
3
|
+
rcs_openapi/errors.py,sha256=SE6_LO_oxFMV0ZuHrQO4_nSC8IOncIDWg5p-O9YYYVk,1115
|
|
4
|
+
rcs_openapi/pii.py,sha256=KDAoOKPG6tOtQlxqwHcO-3Xmi95RDqzesT5pMJtPXrA,2225
|
|
5
|
+
rcs_openapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
rcs_openapi/rules.py,sha256=rJg3xNhQ_UCpB3nvcTXo3shF_KluUiChsjE_Ld4NAjs,4092
|
|
7
|
+
rcs_openapi/types.py,sha256=wSF_pfoqC4nETthvMZuyNbxdOqhSUZ4EmvF82w__rDw,2025
|
|
8
|
+
rcs_openapi/webhook.py,sha256=WTuT2vjLJipJaO3sxWCoq0z48e13q6ngBxJR8GaZai8,9556
|
|
9
|
+
rcs_openapi_client-0.5.0.dist-info/licenses/LICENSE,sha256=bYc_KGnynGR-xE1LXkV1StYnRE6NJgnu5d2Sz_e9ggM,149
|
|
10
|
+
rcs_openapi_client-0.5.0.dist-info/METADATA,sha256=ESbzPnFFdDG9L4egBEUzlUzBC5XheyUvjACZSzuVEfc,12503
|
|
11
|
+
rcs_openapi_client-0.5.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
rcs_openapi_client-0.5.0.dist-info/top_level.txt,sha256=34JLKfruFbjViNms9F7P2P3RyT21Gxr5FPju9pohj1c,12
|
|
13
|
+
rcs_openapi_client-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rcs_openapi
|