tinet-cticloud-cli 0.0.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tinet_cticloud_cli-0.0.5/PKG-INFO +14 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/__init__.py +3 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/__main__.py +4 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/core/auth.py +54 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/core/cdr.py +112 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/core/cdr_query.py +232 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/core/client.py +112 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/core/config.py +100 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/core/record.py +130 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/core/record_query.py +59 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/core/task.py +331 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/core/task_create.py +164 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/core/task_fields.py +13 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/core/task_lifecycle.py +70 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/core/task_query.py +79 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/core/task_update.py +97 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/cticloud_cli.py +562 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/conftest.py +26 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_auth.py +34 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_cdr.py +109 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_cdr_query.py +64 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_cli.py +453 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_client.py +25 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_config.py +26 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_csv_importer.py +46 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_full_e2e.py +427 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_integration_live.py +99 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_output.py +13 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_record.py +154 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_record_query.py +41 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_smoke.py +5 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_task.py +200 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_task_create.py +107 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_task_fields.py +11 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_task_lifecycle.py +84 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_task_query_get.py +90 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/tests/test_task_update.py +69 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/utils/cdr_cli_options.py +151 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/utils/csv_importer.py +54 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/utils/output.py +62 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/utils/task_create_cli_options.py +32 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/utils/task_field_cli_options.py +58 -0
- tinet_cticloud_cli-0.0.5/cli_anything/cticloud/utils/task_update_cli_options.py +25 -0
- tinet_cticloud_cli-0.0.5/pyproject.toml +3 -0
- tinet_cticloud_cli-0.0.5/setup.cfg +4 -0
- tinet_cticloud_cli-0.0.5/setup.py +24 -0
- tinet_cticloud_cli-0.0.5/tinet_cticloud_cli.egg-info/PKG-INFO +14 -0
- tinet_cticloud_cli-0.0.5/tinet_cticloud_cli.egg-info/SOURCES.txt +50 -0
- tinet_cticloud_cli-0.0.5/tinet_cticloud_cli.egg-info/dependency_links.txt +1 -0
- tinet_cticloud_cli-0.0.5/tinet_cticloud_cli.egg-info/entry_points.txt +2 -0
- tinet_cticloud_cli-0.0.5/tinet_cticloud_cli.egg-info/requires.txt +6 -0
- tinet_cticloud_cli-0.0.5/tinet_cticloud_cli.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tinet-cticloud-cli
|
|
3
|
+
Version: 0.0.5
|
|
4
|
+
Summary: CLI harness for CTICloud predictive outbound task APIs
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: click>=8.0
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest-httpx>=0.30; extra == "dev"
|
|
11
|
+
Dynamic: provides-extra
|
|
12
|
+
Dynamic: requires-dist
|
|
13
|
+
Dynamic: requires-python
|
|
14
|
+
Dynamic: summary
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""MD5 sign and auth query parameter helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from cli_anything.cticloud.core.config import CtiConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_sign(
|
|
13
|
+
*,
|
|
14
|
+
validate_type: int,
|
|
15
|
+
enterprise_id: Optional[str],
|
|
16
|
+
department_id: Optional[str],
|
|
17
|
+
timestamp: int,
|
|
18
|
+
token: str,
|
|
19
|
+
) -> str:
|
|
20
|
+
"""Compute platform sign: MD5(id + timestamp + token), 32-char lowercase hex."""
|
|
21
|
+
if validate_type == 1:
|
|
22
|
+
if not department_id:
|
|
23
|
+
raise ValueError("validateType=1 需要 departmentId")
|
|
24
|
+
raw = f"{department_id}{timestamp}{token}"
|
|
25
|
+
elif validate_type == 2:
|
|
26
|
+
if not enterprise_id:
|
|
27
|
+
raise ValueError("validateType=2 需要 enterpriseId")
|
|
28
|
+
raw = f"{enterprise_id}{timestamp}{token}"
|
|
29
|
+
else:
|
|
30
|
+
raise ValueError(f"不支持的 validateType: {validate_type}")
|
|
31
|
+
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def auth_query_params(config: CtiConfig, timestamp: Optional[int] = None) -> Dict[str, Any]:
|
|
35
|
+
"""Build validateType, ids, timestamp, and sign query parameters."""
|
|
36
|
+
config.validate_ready()
|
|
37
|
+
ts = timestamp if timestamp is not None else int(time.time())
|
|
38
|
+
sign = build_sign(
|
|
39
|
+
validate_type=config.validate_type,
|
|
40
|
+
enterprise_id=config.enterprise_id,
|
|
41
|
+
department_id=config.department_id,
|
|
42
|
+
timestamp=ts,
|
|
43
|
+
token=config.token or "",
|
|
44
|
+
)
|
|
45
|
+
params: Dict[str, Any] = {
|
|
46
|
+
"validateType": str(config.validate_type),
|
|
47
|
+
"timestamp": str(ts),
|
|
48
|
+
"sign": sign,
|
|
49
|
+
}
|
|
50
|
+
if config.validate_type == 1:
|
|
51
|
+
params["departmentId"] = config.department_id
|
|
52
|
+
else:
|
|
53
|
+
params["enterpriseId"] = config.enterprise_id
|
|
54
|
+
return params
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""CDR list-auto-task operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import Any, Dict, List
|
|
7
|
+
|
|
8
|
+
from cli_anything.cticloud.core.client import CcClient
|
|
9
|
+
from cli_anything.cticloud.core.config import CtiConfig
|
|
10
|
+
from cli_anything.cticloud.core.cdr_query import (
|
|
11
|
+
ListCdrAutoTaskQuery,
|
|
12
|
+
build_list_cdr_auto_task_params,
|
|
13
|
+
validate_list_cdr_auto_task_query,
|
|
14
|
+
validate_time_range,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ListCdrAutoTaskQuery",
|
|
19
|
+
"build_list_cdr_auto_task_params",
|
|
20
|
+
"list_cdr_auto_task",
|
|
21
|
+
"validate_list_cdr_auto_task_query",
|
|
22
|
+
"validate_time_range",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def list_cdr_auto_task(
|
|
27
|
+
config: CtiConfig,
|
|
28
|
+
query: ListCdrAutoTaskQuery,
|
|
29
|
+
*,
|
|
30
|
+
fetch_all: bool = False,
|
|
31
|
+
verbose: bool = False,
|
|
32
|
+
) -> Dict[str, Any]:
|
|
33
|
+
"""Query task call records via /cc/list_cdr_auto_task."""
|
|
34
|
+
validate_list_cdr_auto_task_query(query)
|
|
35
|
+
client = CcClient(config)
|
|
36
|
+
|
|
37
|
+
def _fetch(current: ListCdrAutoTaskQuery) -> dict:
|
|
38
|
+
params = build_list_cdr_auto_task_params(current)
|
|
39
|
+
return client.get("list_cdr_auto_task", params, verbose=verbose)
|
|
40
|
+
|
|
41
|
+
if not fetch_all:
|
|
42
|
+
return _fetch(query)
|
|
43
|
+
|
|
44
|
+
if query.scroll_search:
|
|
45
|
+
return _fetch_all_scroll(query, _fetch)
|
|
46
|
+
|
|
47
|
+
return _fetch_all_offset(query, _fetch)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _fetch_all_offset(
|
|
51
|
+
query: ListCdrAutoTaskQuery,
|
|
52
|
+
fetch_page,
|
|
53
|
+
) -> Dict[str, Any]:
|
|
54
|
+
"""Paginate with offset/limit until all records are retrieved."""
|
|
55
|
+
responses: List[dict] = []
|
|
56
|
+
all_records: List[dict] = []
|
|
57
|
+
off = query.offset
|
|
58
|
+
page_limit = max(10, min(query.limit, 100))
|
|
59
|
+
platform_total = None
|
|
60
|
+
|
|
61
|
+
while True:
|
|
62
|
+
page_query = replace(query, offset=off, limit=page_limit)
|
|
63
|
+
page = fetch_page(page_query)
|
|
64
|
+
responses.append(page)
|
|
65
|
+
batch = page.get("cdrAutoTask") or []
|
|
66
|
+
all_records.extend(batch)
|
|
67
|
+
if platform_total is None:
|
|
68
|
+
platform_total = int(page.get("totalCount") or 0)
|
|
69
|
+
if not batch or len(all_records) >= platform_total:
|
|
70
|
+
break
|
|
71
|
+
off += page_limit
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
"responses": responses,
|
|
75
|
+
"summary": {
|
|
76
|
+
"totalCount": platform_total if platform_total is not None else len(all_records),
|
|
77
|
+
"cdrAutoTask": all_records,
|
|
78
|
+
"pages": len(responses),
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _fetch_all_scroll(
|
|
84
|
+
query: ListCdrAutoTaskQuery,
|
|
85
|
+
fetch_page,
|
|
86
|
+
) -> Dict[str, Any]:
|
|
87
|
+
"""Paginate with scrollId until scroll ends."""
|
|
88
|
+
responses: List[dict] = []
|
|
89
|
+
all_records: List[dict] = []
|
|
90
|
+
scroll_id = query.scroll_id
|
|
91
|
+
platform_total = None
|
|
92
|
+
|
|
93
|
+
while True:
|
|
94
|
+
page_query = replace(query, scroll_id=scroll_id, scroll_search=True)
|
|
95
|
+
page = fetch_page(page_query)
|
|
96
|
+
responses.append(page)
|
|
97
|
+
batch = page.get("cdrAutoTask") or []
|
|
98
|
+
all_records.extend(batch)
|
|
99
|
+
if platform_total is None:
|
|
100
|
+
platform_total = int(page.get("totalCount") or 0)
|
|
101
|
+
scroll_id = page.get("scrollId")
|
|
102
|
+
if not scroll_id or not batch:
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
"responses": responses,
|
|
107
|
+
"summary": {
|
|
108
|
+
"totalCount": platform_total if platform_total is not None else len(all_records),
|
|
109
|
+
"cdrAutoTask": all_records,
|
|
110
|
+
"pages": len(responses),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Wiki-aligned query model for /cc/list_cdr_auto_task."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, fields
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
MAX_RANGE_SECONDS = 31 * 86400
|
|
9
|
+
MIN_LIMIT = 10
|
|
10
|
+
MAX_LIMIT = 100
|
|
11
|
+
MAX_OFFSET = 99990
|
|
12
|
+
MAX_OFFSET_PLUS_LIMIT = 100_000
|
|
13
|
+
|
|
14
|
+
# (snake_field, wiki_key, is_array)
|
|
15
|
+
WIKI_QUERY_FIELDS: Tuple[Tuple[str, str, bool], ...] = (
|
|
16
|
+
("up_time", "upTime", False),
|
|
17
|
+
("up_time_end", "upTimeEnd", False),
|
|
18
|
+
("end_time", "endTime", False),
|
|
19
|
+
("end_time_end", "endTimeEnd", False),
|
|
20
|
+
("hidden_type", "hiddenType", False),
|
|
21
|
+
("customer_bridge_duration", "customerBridgeDuration", False),
|
|
22
|
+
("customer_bridge_duration_end", "customerBridgeDurationEnd", False),
|
|
23
|
+
("total_duration", "totalDuration", False),
|
|
24
|
+
("total_duration_end", "totalDurationEnd", False),
|
|
25
|
+
("cnos", "cnos", True),
|
|
26
|
+
("qnos", "qnos", True),
|
|
27
|
+
("client_number", "clientNumber", False),
|
|
28
|
+
("customer_number", "customerNumber", False),
|
|
29
|
+
("customer_number_match_type", "customerNumberMatchType", False),
|
|
30
|
+
("hotlines", "hotlines", True),
|
|
31
|
+
("xnumber", "xnumber", True),
|
|
32
|
+
("status", "status", True),
|
|
33
|
+
("sip_cause_type", "sipCauseType", True),
|
|
34
|
+
("sip_cause", "sipCause", True),
|
|
35
|
+
("sip_cause_raw", "sipCauseRaw", True),
|
|
36
|
+
("on_hook_source", "onHookSource", True),
|
|
37
|
+
("investigation_keys", "investigationKeys", False),
|
|
38
|
+
("province", "province", False),
|
|
39
|
+
("city", "city", False),
|
|
40
|
+
("main_unique_id", "mainUniqueId", False),
|
|
41
|
+
("request_unique_id", "requestUniqueId", False),
|
|
42
|
+
("evaluation", "evaluation", False),
|
|
43
|
+
("task_ids", "taskIds", True),
|
|
44
|
+
("ivr_ids", "ivrIds", True),
|
|
45
|
+
("degrade", "degrade", False),
|
|
46
|
+
("tel_retry", "telRetry", False),
|
|
47
|
+
("ab_task_group_name", "abTaskGroupName", False),
|
|
48
|
+
("finish_retry_flag", "finishRetryFlag", False),
|
|
49
|
+
("business_tag_names", "businessTagNames", True),
|
|
50
|
+
("user_field", "userField", False),
|
|
51
|
+
("user_value", "userValue", False),
|
|
52
|
+
("customer_mos", "customerMos", True),
|
|
53
|
+
("agent_mos", "agentMos", True),
|
|
54
|
+
("intelligent_bridge_time", "intelligentBridgeTime", False),
|
|
55
|
+
("intelligent_bridge_time_end", "intelligentBridgeTimeEnd", False),
|
|
56
|
+
("intelligent_end_time", "intelligentEndTime", False),
|
|
57
|
+
("intelligent_end_time_end", "intelligentEndTimeEnd", False),
|
|
58
|
+
("intelligent_bridge_duration", "intelligentBridgeDuration", False),
|
|
59
|
+
("intelligent_bridge_duration_end", "intelligentBridgeDurationEnd", False),
|
|
60
|
+
("intelligent_bot_ids", "intelligentBotIds", True),
|
|
61
|
+
("intelligent_status", "intelligentStatus", True),
|
|
62
|
+
("intelligent_status_result", "intelligentStatusResult", True),
|
|
63
|
+
("intelligent_round", "intelligentRound", False),
|
|
64
|
+
("intelligent_round_end", "intelligentRoundEnd", False),
|
|
65
|
+
("intelligent_interrupt_count", "intelligentInterruptCount", False),
|
|
66
|
+
("intelligent_interrupt_count_end", "intelligentInterruptCountEnd", False),
|
|
67
|
+
("intelligent_transfer", "intelligentTransfer", False),
|
|
68
|
+
("intelligent_greeting", "intelligentGreeting", False),
|
|
69
|
+
("intelligent_greeting_end", "intelligentGreetingEnd", False),
|
|
70
|
+
("intelligent_end_flow", "intelligentEndFlow", False),
|
|
71
|
+
("intelligent_intention_tag", "intelligentIntentionTag", False),
|
|
72
|
+
("intelligent_info", "intelligentInfo", False),
|
|
73
|
+
("scroll_search", "scrollSearch", False),
|
|
74
|
+
("scroll_id", "scrollId", False),
|
|
75
|
+
("offset", "offset", False),
|
|
76
|
+
("limit", "limit", False),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ListCdrAutoTaskQuery:
|
|
82
|
+
"""All Wiki query parameters for list_cdr_auto_task."""
|
|
83
|
+
|
|
84
|
+
start_time: int
|
|
85
|
+
start_time_end: int
|
|
86
|
+
up_time: Optional[int] = None
|
|
87
|
+
up_time_end: Optional[int] = None
|
|
88
|
+
end_time: Optional[int] = None
|
|
89
|
+
end_time_end: Optional[int] = None
|
|
90
|
+
hidden_type: Optional[int] = None
|
|
91
|
+
customer_bridge_duration: Optional[int] = None
|
|
92
|
+
customer_bridge_duration_end: Optional[int] = None
|
|
93
|
+
total_duration: Optional[int] = None
|
|
94
|
+
total_duration_end: Optional[int] = None
|
|
95
|
+
cnos: Optional[List[str]] = None
|
|
96
|
+
qnos: Optional[List[str]] = None
|
|
97
|
+
client_number: Optional[str] = None
|
|
98
|
+
customer_number: Optional[str] = None
|
|
99
|
+
customer_number_match_type: Optional[int] = None
|
|
100
|
+
hotlines: Optional[List[str]] = None
|
|
101
|
+
xnumber: Optional[List[str]] = None
|
|
102
|
+
status: Optional[List[int]] = None
|
|
103
|
+
sip_cause_type: Optional[List[int]] = None
|
|
104
|
+
sip_cause: Optional[List[int]] = None
|
|
105
|
+
sip_cause_raw: Optional[List[int]] = None
|
|
106
|
+
on_hook_source: Optional[List[int]] = None
|
|
107
|
+
investigation_keys: Optional[int] = None
|
|
108
|
+
province: Optional[str] = None
|
|
109
|
+
city: Optional[str] = None
|
|
110
|
+
main_unique_id: Optional[str] = None
|
|
111
|
+
request_unique_id: Optional[str] = None
|
|
112
|
+
evaluation: Optional[int] = None
|
|
113
|
+
task_ids: Optional[List[str]] = None
|
|
114
|
+
ivr_ids: Optional[List[int]] = None
|
|
115
|
+
degrade: Optional[int] = None
|
|
116
|
+
tel_retry: Optional[int] = None
|
|
117
|
+
ab_task_group_name: Optional[str] = None
|
|
118
|
+
finish_retry_flag: Optional[int] = None
|
|
119
|
+
business_tag_names: Optional[List[str]] = None
|
|
120
|
+
user_field: Optional[str] = None
|
|
121
|
+
user_value: Optional[str] = None
|
|
122
|
+
customer_mos: Optional[List[int]] = None
|
|
123
|
+
agent_mos: Optional[List[int]] = None
|
|
124
|
+
intelligent_bridge_time: Optional[int] = None
|
|
125
|
+
intelligent_bridge_time_end: Optional[int] = None
|
|
126
|
+
intelligent_end_time: Optional[int] = None
|
|
127
|
+
intelligent_end_time_end: Optional[int] = None
|
|
128
|
+
intelligent_bridge_duration: Optional[int] = None
|
|
129
|
+
intelligent_bridge_duration_end: Optional[int] = None
|
|
130
|
+
intelligent_bot_ids: Optional[List[str]] = None
|
|
131
|
+
intelligent_status: Optional[List[int]] = None
|
|
132
|
+
intelligent_status_result: Optional[List[int]] = None
|
|
133
|
+
intelligent_round: Optional[int] = None
|
|
134
|
+
intelligent_round_end: Optional[int] = None
|
|
135
|
+
intelligent_interrupt_count: Optional[int] = None
|
|
136
|
+
intelligent_interrupt_count_end: Optional[int] = None
|
|
137
|
+
intelligent_transfer: Optional[int] = None
|
|
138
|
+
intelligent_greeting: Optional[int] = None
|
|
139
|
+
intelligent_greeting_end: Optional[int] = None
|
|
140
|
+
intelligent_end_flow: Optional[str] = None
|
|
141
|
+
intelligent_intention_tag: Optional[str] = None
|
|
142
|
+
intelligent_info: Optional[str] = None
|
|
143
|
+
scroll_search: Optional[bool] = None
|
|
144
|
+
scroll_id: Optional[str] = None
|
|
145
|
+
offset: int = 0
|
|
146
|
+
limit: int = 10
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def validate_time_range(start_time: int, start_time_end: int) -> None:
|
|
150
|
+
"""Reject query windows wider than 31 days."""
|
|
151
|
+
if start_time_end < start_time:
|
|
152
|
+
raise ValueError("startTimeEnd 不能早于 startTime")
|
|
153
|
+
if start_time_end - start_time > MAX_RANGE_SECONDS:
|
|
154
|
+
raise ValueError("查询时间跨度不能超过 31 天")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _require_pair(a: Optional[int], b: Optional[int], label: str) -> None:
|
|
158
|
+
if (a is None) != (b is None):
|
|
159
|
+
raise ValueError(f"{label} 须成对传入")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def validate_list_cdr_auto_task_query(query: ListCdrAutoTaskQuery) -> None:
|
|
163
|
+
"""Validate Wiki constraints before HTTP."""
|
|
164
|
+
validate_time_range(query.start_time, query.start_time_end)
|
|
165
|
+
_require_pair(query.up_time, query.up_time_end, "upTime/upTimeEnd")
|
|
166
|
+
_require_pair(query.end_time, query.end_time_end, "endTime/endTimeEnd")
|
|
167
|
+
_require_pair(
|
|
168
|
+
query.customer_bridge_duration,
|
|
169
|
+
query.customer_bridge_duration_end,
|
|
170
|
+
"customerBridgeDuration/customerBridgeDurationEnd",
|
|
171
|
+
)
|
|
172
|
+
_require_pair(query.total_duration, query.total_duration_end, "totalDuration/totalDurationEnd")
|
|
173
|
+
_require_pair(
|
|
174
|
+
query.intelligent_bridge_time,
|
|
175
|
+
query.intelligent_bridge_time_end,
|
|
176
|
+
"intelligentBridgeTime/intelligentBridgeTimeEnd",
|
|
177
|
+
)
|
|
178
|
+
_require_pair(
|
|
179
|
+
query.intelligent_end_time,
|
|
180
|
+
query.intelligent_end_time_end,
|
|
181
|
+
"intelligentEndTime/intelligentEndTimeEnd",
|
|
182
|
+
)
|
|
183
|
+
_require_pair(
|
|
184
|
+
query.intelligent_bridge_duration,
|
|
185
|
+
query.intelligent_bridge_duration_end,
|
|
186
|
+
"intelligentBridgeDuration/intelligentBridgeDurationEnd",
|
|
187
|
+
)
|
|
188
|
+
_require_pair(query.intelligent_round, query.intelligent_round_end, "intelligentRound/intelligentRoundEnd")
|
|
189
|
+
_require_pair(
|
|
190
|
+
query.intelligent_interrupt_count,
|
|
191
|
+
query.intelligent_interrupt_count_end,
|
|
192
|
+
"intelligentInterruptCount/intelligentInterruptCountEnd",
|
|
193
|
+
)
|
|
194
|
+
_require_pair(
|
|
195
|
+
query.intelligent_greeting,
|
|
196
|
+
query.intelligent_greeting_end,
|
|
197
|
+
"intelligentGreeting/intelligentGreetingEnd",
|
|
198
|
+
)
|
|
199
|
+
if query.limit < MIN_LIMIT or query.limit > MAX_LIMIT:
|
|
200
|
+
raise ValueError(f"limit 须在 {MIN_LIMIT}–{MAX_LIMIT} 之间")
|
|
201
|
+
if query.offset < 0 or query.offset > MAX_OFFSET:
|
|
202
|
+
raise ValueError(f"offset 须在 0–{MAX_OFFSET} 之间")
|
|
203
|
+
if query.offset + query.limit > MAX_OFFSET_PLUS_LIMIT:
|
|
204
|
+
raise ValueError("limit + offset 不允许超过 100000")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def build_list_cdr_auto_task_params(query: ListCdrAutoTaskQuery) -> Dict[str, Any]:
|
|
208
|
+
"""Convert query model to CC API query dict (Wiki camelCase keys)."""
|
|
209
|
+
params: Dict[str, Any] = {
|
|
210
|
+
"startTime": str(query.start_time),
|
|
211
|
+
"startTimeEnd": str(query.start_time_end),
|
|
212
|
+
"offset": str(query.offset),
|
|
213
|
+
"limit": str(query.limit),
|
|
214
|
+
}
|
|
215
|
+
for snake, wiki_key, is_array in WIKI_QUERY_FIELDS:
|
|
216
|
+
if snake in ("offset", "limit"):
|
|
217
|
+
continue
|
|
218
|
+
value = getattr(query, snake)
|
|
219
|
+
if value is None:
|
|
220
|
+
continue
|
|
221
|
+
if snake == "scroll_search":
|
|
222
|
+
params[wiki_key] = "true" if value else "false"
|
|
223
|
+
elif is_array:
|
|
224
|
+
params[wiki_key] = value
|
|
225
|
+
else:
|
|
226
|
+
params[wiki_key] = str(value)
|
|
227
|
+
return params
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def list_cdr_query_field_names() -> List[str]:
|
|
231
|
+
"""Return dataclass field names excluding start_time/start_time_end."""
|
|
232
|
+
return [f.name for f in fields(ListCdrAutoTaskQuery) if f.name not in ("start_time", "start_time_end")]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""HTTP clients for CTICloud interface and CC gateways."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
from urllib.parse import urlencode
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from cli_anything.cticloud.core.auth import auth_query_params
|
|
12
|
+
from cli_anything.cticloud.core.config import CtiConfig
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CtiApiError(Exception):
|
|
18
|
+
"""Raised when the platform returns an error response."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, payload: Optional[dict] = None):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.payload = payload or {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _region_host(region: str) -> str:
|
|
26
|
+
return f"https://api-{region}.cticloud.cn"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def log_request(method: str, url: str, verbose: bool = False) -> None:
|
|
30
|
+
"""Log request metadata without secrets."""
|
|
31
|
+
if verbose:
|
|
32
|
+
safe_url = url.split("sign=")[0] if "sign=" in url else url
|
|
33
|
+
logger.info("%s %s", method, safe_url)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InterfaceClient:
|
|
37
|
+
"""Client for /interface/v10 endpoints."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: CtiConfig, timeout: float = 60.0):
|
|
40
|
+
self.config = config
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
self.base_url = f"{_region_host(config.region)}/interface/v10"
|
|
43
|
+
|
|
44
|
+
def make_url(self, path: str, params: Optional[Dict[str, Any]] = None) -> str:
|
|
45
|
+
"""Build full URL with query string (no auth)."""
|
|
46
|
+
path = path if path.startswith("/") else f"/{path}"
|
|
47
|
+
query = urlencode(params or {}, doseq=True)
|
|
48
|
+
return f"{self.base_url}{path}" + (f"?{query}" if query else "")
|
|
49
|
+
|
|
50
|
+
def _merge_params(self, params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
51
|
+
merged = dict(auth_query_params(self.config))
|
|
52
|
+
if params:
|
|
53
|
+
merged.update({k: v for k, v in params.items() if v is not None and v != ""})
|
|
54
|
+
return merged
|
|
55
|
+
|
|
56
|
+
def get(
|
|
57
|
+
self,
|
|
58
|
+
path: str,
|
|
59
|
+
params: Optional[Dict[str, Any]] = None,
|
|
60
|
+
verbose: bool = False,
|
|
61
|
+
) -> dict:
|
|
62
|
+
"""Perform authenticated GET and return parsed JSON."""
|
|
63
|
+
merged = self._merge_params(params)
|
|
64
|
+
url = self.make_url(path, merged)
|
|
65
|
+
log_request("GET", url, verbose=verbose)
|
|
66
|
+
response = httpx.get(url, timeout=self.timeout)
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
return response.json()
|
|
69
|
+
|
|
70
|
+
def post_json(
|
|
71
|
+
self,
|
|
72
|
+
path: str,
|
|
73
|
+
body: dict,
|
|
74
|
+
params: Optional[Dict[str, Any]] = None,
|
|
75
|
+
verbose: bool = False,
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""Perform authenticated POST with JSON body."""
|
|
78
|
+
merged = self._merge_params(params)
|
|
79
|
+
url = self.make_url(path, merged)
|
|
80
|
+
log_request("POST", url, verbose=verbose)
|
|
81
|
+
response = httpx.post(url, json=body, timeout=self.timeout)
|
|
82
|
+
response.raise_for_status()
|
|
83
|
+
return response.json()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CcClient:
|
|
87
|
+
"""Client for /cc endpoints."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, config: CtiConfig, timeout: float = 60.0):
|
|
90
|
+
self.config = config
|
|
91
|
+
self.timeout = timeout
|
|
92
|
+
self.base_url = f"{_region_host(config.region)}/cc"
|
|
93
|
+
|
|
94
|
+
def make_url(self, path: str, params: Optional[Dict[str, Any]] = None) -> str:
|
|
95
|
+
path = path.lstrip("/")
|
|
96
|
+
query = urlencode(params or {}, doseq=True)
|
|
97
|
+
return f"{self.base_url}/{path}" + (f"?{query}" if query else "")
|
|
98
|
+
|
|
99
|
+
def get(
|
|
100
|
+
self,
|
|
101
|
+
path: str,
|
|
102
|
+
params: Optional[Dict[str, Any]] = None,
|
|
103
|
+
verbose: bool = False,
|
|
104
|
+
) -> dict:
|
|
105
|
+
merged = dict(auth_query_params(self.config))
|
|
106
|
+
if params:
|
|
107
|
+
merged.update({k: v for k, v in params.items() if v is not None and v != ""})
|
|
108
|
+
url = self.make_url(path, merged)
|
|
109
|
+
log_request("GET", url, verbose=verbose)
|
|
110
|
+
response = httpx.get(url, timeout=self.timeout)
|
|
111
|
+
response.raise_for_status()
|
|
112
|
+
return response.json()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""CTICloud CLI configuration loading and persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".cticloud" / "config.json"
|
|
12
|
+
|
|
13
|
+
ENV_MAP = {
|
|
14
|
+
"region": "CTICLOUD_REGION",
|
|
15
|
+
"validate_type": "CTICLOUD_VALIDATE_TYPE",
|
|
16
|
+
"enterprise_id": "CTICLOUD_ENTERPRISE_ID",
|
|
17
|
+
"department_id": "CTICLOUD_DEPARTMENT_ID",
|
|
18
|
+
"token": "CTICLOUD_TOKEN",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CtiConfig:
|
|
24
|
+
"""Runtime credentials and region settings for CTICloud API calls."""
|
|
25
|
+
|
|
26
|
+
region: str = "1"
|
|
27
|
+
validate_type: int = 2
|
|
28
|
+
enterprise_id: Optional[str] = None
|
|
29
|
+
department_id: Optional[str] = None
|
|
30
|
+
token: Optional[str] = None
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str:
|
|
33
|
+
return (
|
|
34
|
+
f"CtiConfig(region={self.region!r}, validate_type={self.validate_type}, "
|
|
35
|
+
f"enterprise_id={self.enterprise_id!r}, department_id={self.department_id!r}, "
|
|
36
|
+
f"token='***')"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def validate_ready(self) -> None:
|
|
40
|
+
"""Ensure required auth fields are present before API calls."""
|
|
41
|
+
if not self.token:
|
|
42
|
+
raise ValueError("缺少 token,请设置 CTICLOUD_TOKEN 或 config set")
|
|
43
|
+
if self.validate_type == 1 and not self.department_id:
|
|
44
|
+
raise ValueError("validateType=1 需要 departmentId")
|
|
45
|
+
if self.validate_type == 2 and not self.enterprise_id:
|
|
46
|
+
raise ValueError("validateType=2 需要 enterpriseId")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_int(value: str) -> int:
|
|
50
|
+
return int(value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_config(config_path: Optional[Path] = None) -> CtiConfig:
|
|
54
|
+
"""Load configuration from file, overridden by environment variables."""
|
|
55
|
+
path = config_path or DEFAULT_CONFIG_PATH
|
|
56
|
+
data: dict[str, Any] = {}
|
|
57
|
+
if path.exists():
|
|
58
|
+
with path.open(encoding="utf-8") as fh:
|
|
59
|
+
data = json.load(fh)
|
|
60
|
+
|
|
61
|
+
cfg = CtiConfig(
|
|
62
|
+
region=str(data.get("region", "1")),
|
|
63
|
+
validate_type=int(data.get("validate_type", 2)),
|
|
64
|
+
enterprise_id=data.get("enterprise_id"),
|
|
65
|
+
department_id=data.get("department_id"),
|
|
66
|
+
token=data.get("token"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if os.getenv(ENV_MAP["region"]):
|
|
70
|
+
cfg.region = os.getenv(ENV_MAP["region"], cfg.region)
|
|
71
|
+
if os.getenv(ENV_MAP["validate_type"]):
|
|
72
|
+
cfg.validate_type = _parse_int(os.getenv(ENV_MAP["validate_type"], "2"))
|
|
73
|
+
if os.getenv(ENV_MAP["enterprise_id"]):
|
|
74
|
+
cfg.enterprise_id = os.getenv(ENV_MAP["enterprise_id"])
|
|
75
|
+
if os.getenv(ENV_MAP["department_id"]):
|
|
76
|
+
cfg.department_id = os.getenv(ENV_MAP["department_id"])
|
|
77
|
+
if os.getenv(ENV_MAP["token"]):
|
|
78
|
+
cfg.token = os.getenv(ENV_MAP["token"])
|
|
79
|
+
|
|
80
|
+
return cfg
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save_config(cfg: CtiConfig, config_path: Optional[Path] = None) -> Path:
|
|
84
|
+
"""Persist configuration to disk with restrictive permissions on Unix."""
|
|
85
|
+
path = config_path or DEFAULT_CONFIG_PATH
|
|
86
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
payload = {
|
|
88
|
+
"region": cfg.region,
|
|
89
|
+
"validate_type": cfg.validate_type,
|
|
90
|
+
"enterprise_id": cfg.enterprise_id,
|
|
91
|
+
"department_id": cfg.department_id,
|
|
92
|
+
"token": cfg.token,
|
|
93
|
+
}
|
|
94
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
95
|
+
json.dump(payload, fh, indent=2)
|
|
96
|
+
try:
|
|
97
|
+
path.chmod(0o600)
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
return path
|