tinet-cticloud-cli 0.0.5__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.
Files changed (47) hide show
  1. cli_anything/cticloud/__init__.py +3 -0
  2. cli_anything/cticloud/__main__.py +4 -0
  3. cli_anything/cticloud/core/auth.py +54 -0
  4. cli_anything/cticloud/core/cdr.py +112 -0
  5. cli_anything/cticloud/core/cdr_query.py +232 -0
  6. cli_anything/cticloud/core/client.py +112 -0
  7. cli_anything/cticloud/core/config.py +100 -0
  8. cli_anything/cticloud/core/record.py +130 -0
  9. cli_anything/cticloud/core/record_query.py +59 -0
  10. cli_anything/cticloud/core/task.py +331 -0
  11. cli_anything/cticloud/core/task_create.py +164 -0
  12. cli_anything/cticloud/core/task_fields.py +13 -0
  13. cli_anything/cticloud/core/task_lifecycle.py +70 -0
  14. cli_anything/cticloud/core/task_query.py +79 -0
  15. cli_anything/cticloud/core/task_update.py +97 -0
  16. cli_anything/cticloud/cticloud_cli.py +562 -0
  17. cli_anything/cticloud/tests/conftest.py +26 -0
  18. cli_anything/cticloud/tests/test_auth.py +34 -0
  19. cli_anything/cticloud/tests/test_cdr.py +109 -0
  20. cli_anything/cticloud/tests/test_cdr_query.py +64 -0
  21. cli_anything/cticloud/tests/test_cli.py +453 -0
  22. cli_anything/cticloud/tests/test_client.py +25 -0
  23. cli_anything/cticloud/tests/test_config.py +26 -0
  24. cli_anything/cticloud/tests/test_csv_importer.py +46 -0
  25. cli_anything/cticloud/tests/test_full_e2e.py +427 -0
  26. cli_anything/cticloud/tests/test_integration_live.py +99 -0
  27. cli_anything/cticloud/tests/test_output.py +13 -0
  28. cli_anything/cticloud/tests/test_record.py +154 -0
  29. cli_anything/cticloud/tests/test_record_query.py +41 -0
  30. cli_anything/cticloud/tests/test_smoke.py +5 -0
  31. cli_anything/cticloud/tests/test_task.py +200 -0
  32. cli_anything/cticloud/tests/test_task_create.py +107 -0
  33. cli_anything/cticloud/tests/test_task_fields.py +11 -0
  34. cli_anything/cticloud/tests/test_task_lifecycle.py +84 -0
  35. cli_anything/cticloud/tests/test_task_query_get.py +90 -0
  36. cli_anything/cticloud/tests/test_task_update.py +69 -0
  37. cli_anything/cticloud/utils/cdr_cli_options.py +151 -0
  38. cli_anything/cticloud/utils/csv_importer.py +54 -0
  39. cli_anything/cticloud/utils/output.py +62 -0
  40. cli_anything/cticloud/utils/task_create_cli_options.py +32 -0
  41. cli_anything/cticloud/utils/task_field_cli_options.py +58 -0
  42. cli_anything/cticloud/utils/task_update_cli_options.py +25 -0
  43. tinet_cticloud_cli-0.0.5.dist-info/METADATA +14 -0
  44. tinet_cticloud_cli-0.0.5.dist-info/RECORD +47 -0
  45. tinet_cticloud_cli-0.0.5.dist-info/WHEEL +5 -0
  46. tinet_cticloud_cli-0.0.5.dist-info/entry_points.txt +2 -0
  47. tinet_cticloud_cli-0.0.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """CTICloud CLI harness for predictive outbound tasks."""
2
+
3
+ __version__ = "0.0.5"
@@ -0,0 +1,4 @@
1
+ from cli_anything.cticloud.cticloud_cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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