hap-cli 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.
- hap_cli/README.md +194 -0
- hap_cli/README_CN.md +601 -0
- hap_cli/__init__.py +3 -0
- hap_cli/commands/__init__.py +1 -0
- hap_cli/commands/ai_cmd.py +224 -0
- hap_cli/commands/app_cmd.py +308 -0
- hap_cli/commands/calendar_cmd.py +138 -0
- hap_cli/commands/chat_cmd.py +101 -0
- hap_cli/commands/config_cmd.py +169 -0
- hap_cli/commands/contact_cmd.py +125 -0
- hap_cli/commands/department_cmd.py +168 -0
- hap_cli/commands/group_cmd.py +128 -0
- hap_cli/commands/instance_cmd.py +310 -0
- hap_cli/commands/node_cmd.py +538 -0
- hap_cli/commands/optionset_cmd.py +99 -0
- hap_cli/commands/page_cmd.py +102 -0
- hap_cli/commands/plugin_cmd.py +133 -0
- hap_cli/commands/post_cmd.py +155 -0
- hap_cli/commands/record_cmd.py +228 -0
- hap_cli/commands/role_cmd.py +221 -0
- hap_cli/commands/workflow_cmd.py +284 -0
- hap_cli/commands/worksheet_cmd.py +342 -0
- hap_cli/context.py +43 -0
- hap_cli/core/__init__.py +1 -0
- hap_cli/core/ai.py +133 -0
- hap_cli/core/app.py +307 -0
- hap_cli/core/auth.py +219 -0
- hap_cli/core/calendar_mod.py +114 -0
- hap_cli/core/chat.py +73 -0
- hap_cli/core/contact.py +85 -0
- hap_cli/core/department.py +131 -0
- hap_cli/core/flow_node.py +1001 -0
- hap_cli/core/group.py +99 -0
- hap_cli/core/instance.py +572 -0
- hap_cli/core/optionset.py +112 -0
- hap_cli/core/page.py +138 -0
- hap_cli/core/plugin.py +87 -0
- hap_cli/core/post.py +118 -0
- hap_cli/core/record.py +268 -0
- hap_cli/core/role.py +227 -0
- hap_cli/core/session.py +348 -0
- hap_cli/core/workflow.py +556 -0
- hap_cli/core/worksheet.py +403 -0
- hap_cli/hap_cli.py +105 -0
- hap_cli/skills/SKILL.md +383 -0
- hap_cli/skills/__init__.py +0 -0
- hap_cli/tests/__init__.py +1 -0
- hap_cli/tests/test_core.py +1824 -0
- hap_cli/tests/test_full_e2e.py +136 -0
- hap_cli/tests/test_integration.py +805 -0
- hap_cli/utils/__init__.py +1 -0
- hap_cli/utils/formatting.py +111 -0
- hap_cli/utils/options.py +10 -0
- hap_cli-0.5.0.dist-info/METADATA +223 -0
- hap_cli-0.5.0.dist-info/RECORD +58 -0
- hap_cli-0.5.0.dist-info/WHEEL +5 -0
- hap_cli-0.5.0.dist-info/entry_points.txt +2 -0
- hap_cli-0.5.0.dist-info/top_level.txt +1 -0
hap_cli/core/app.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Application management module for MingDAO HAP."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from hap_cli.core.session import Session
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_app_info(session: Session, app_id: str) -> dict[str, Any]:
|
|
9
|
+
"""Get application information (basic, no sections).
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
session: Active session
|
|
13
|
+
app_id: Application ID
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Application info dict
|
|
17
|
+
"""
|
|
18
|
+
return session.api_call("HomeApp", "GetApp", {"appId": app_id})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_app_detail(session: Session, app_id: str) -> dict[str, Any]:
|
|
22
|
+
"""Get detailed application information including sections and worksheets.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
session: Active session
|
|
26
|
+
app_id: Application ID
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Application detail dict with sections and worksheet lists
|
|
30
|
+
"""
|
|
31
|
+
return session.api_call(
|
|
32
|
+
"HomeApp", "GetApp",
|
|
33
|
+
{"appId": app_id, "getSection": True, "getManager": True, "getLang": True},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_app_worksheets(
|
|
38
|
+
session: Session, app_id: str, app_section_id: str = ""
|
|
39
|
+
) -> list[dict[str, Any]]:
|
|
40
|
+
"""List worksheets in an application.
|
|
41
|
+
|
|
42
|
+
Calls GetApp with getSection=true, then flattens all workSheetInfo arrays
|
|
43
|
+
from all sections (optionally filtered by appSectionId).
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
session: Active session
|
|
47
|
+
app_id: Application ID
|
|
48
|
+
app_section_id: Optional section ID to filter to a single section
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
List of worksheet info dicts
|
|
52
|
+
"""
|
|
53
|
+
result = session.api_call(
|
|
54
|
+
"HomeApp", "GetApp",
|
|
55
|
+
{"appId": app_id, "getSection": True, "getManager": True, "getLang": True},
|
|
56
|
+
)
|
|
57
|
+
sections = result.get("sections", [])
|
|
58
|
+
worksheets: list[dict[str, Any]] = []
|
|
59
|
+
for sec in sections:
|
|
60
|
+
if app_section_id and sec.get("appSectionId") != app_section_id:
|
|
61
|
+
continue
|
|
62
|
+
worksheets.extend(sec.get("workSheetInfo", []))
|
|
63
|
+
return worksheets
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_managed_apps(session: Session, project_id: str) -> list[dict[str, Any]]:
|
|
67
|
+
"""List applications where the user is a manager.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
session: Active session
|
|
71
|
+
project_id: Project/organization ID
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of application info dicts with appId, name, worksheetCount
|
|
75
|
+
"""
|
|
76
|
+
result = session.api_call(
|
|
77
|
+
"AppManagement", "GetAppForManager",
|
|
78
|
+
{"projectId": project_id, "type": 0},
|
|
79
|
+
)
|
|
80
|
+
apps = result if isinstance(result, list) else []
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
"appId": app.get("appId", ""),
|
|
84
|
+
"name": app.get("appName", ""),
|
|
85
|
+
"worksheetCount": len(app.get("workSheetInfo", [])),
|
|
86
|
+
}
|
|
87
|
+
for app in apps
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_all_home_apps(session: Session, project_id: str) -> list[dict[str, Any]]:
|
|
92
|
+
"""List applications for a specific project.
|
|
93
|
+
|
|
94
|
+
Calls GetAllHomeApp and filters validProject entries by projectId.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
session: Active session
|
|
98
|
+
project_id: Project/organization ID to filter by
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
List of application info dicts with appId, name, orgName, projectId
|
|
102
|
+
"""
|
|
103
|
+
result = session.api_call("HomeApp", "GetAllHomeApp", {"containsLinks": False})
|
|
104
|
+
apps: list[dict[str, Any]] = []
|
|
105
|
+
valid_projects = result if isinstance(result, list) else result.get("validProject", [])
|
|
106
|
+
for project in valid_projects:
|
|
107
|
+
if project.get("projectId") != project_id:
|
|
108
|
+
continue
|
|
109
|
+
org_name = project.get("projectName", "")
|
|
110
|
+
for app in project.get("projectApps", []):
|
|
111
|
+
apps.append({
|
|
112
|
+
"appId": app.get("id", ""),
|
|
113
|
+
"name": app.get("name", ""),
|
|
114
|
+
"orgName": org_name,
|
|
115
|
+
"projectId": project_id,
|
|
116
|
+
})
|
|
117
|
+
return apps
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── App Lifecycle ────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def create_app(
|
|
124
|
+
session: Session,
|
|
125
|
+
project_id: str,
|
|
126
|
+
name: str,
|
|
127
|
+
icon: str = "",
|
|
128
|
+
icon_color: str = "",
|
|
129
|
+
nav_color: str = "",
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""Create a new application."""
|
|
132
|
+
data: dict[str, Any] = {"projectId": project_id, "name": name}
|
|
133
|
+
if icon:
|
|
134
|
+
data["icon"] = icon
|
|
135
|
+
if icon_color:
|
|
136
|
+
data["iconColor"] = icon_color
|
|
137
|
+
if nav_color:
|
|
138
|
+
data["navColor"] = nav_color
|
|
139
|
+
return session.api_call("HomeApp", "CreateApp", data)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def update_app(
|
|
143
|
+
session: Session,
|
|
144
|
+
app_id: str,
|
|
145
|
+
name: str = "",
|
|
146
|
+
description: str = "",
|
|
147
|
+
icon_color: str = "",
|
|
148
|
+
nav_color: str = "",
|
|
149
|
+
project_id: str = "",
|
|
150
|
+
) -> dict[str, Any]:
|
|
151
|
+
"""Update application information."""
|
|
152
|
+
data: dict[str, Any] = {"appId": app_id}
|
|
153
|
+
if name:
|
|
154
|
+
data["name"] = name
|
|
155
|
+
if description:
|
|
156
|
+
data["description"] = description
|
|
157
|
+
if icon_color:
|
|
158
|
+
data["iconColor"] = icon_color
|
|
159
|
+
if nav_color:
|
|
160
|
+
data["navColor"] = nav_color
|
|
161
|
+
if project_id:
|
|
162
|
+
data["projectId"] = project_id
|
|
163
|
+
return session.api_call("HomeApp", "EditAppInfo", data)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def delete_app(session: Session, app_id: str, project_id: str) -> dict[str, Any]:
|
|
167
|
+
"""Delete an application."""
|
|
168
|
+
return session.api_call(
|
|
169
|
+
"HomeApp", "DeleteApp",
|
|
170
|
+
{"appId": app_id, "projectId": project_id},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── Section (app group) management ──────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def add_section(
|
|
178
|
+
session: Session, app_id: str, name: str = "",
|
|
179
|
+
) -> dict[str, Any]:
|
|
180
|
+
"""Add a section/group to an application."""
|
|
181
|
+
data: dict[str, Any] = {"appId": app_id}
|
|
182
|
+
if name:
|
|
183
|
+
data["name"] = name
|
|
184
|
+
return session.api_call("HomeApp", "AddAppSection", data)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def edit_section(
|
|
188
|
+
session: Session, app_id: str, section_id: str, name: str,
|
|
189
|
+
) -> dict[str, Any]:
|
|
190
|
+
"""Edit a section name."""
|
|
191
|
+
return session.api_call(
|
|
192
|
+
"HomeApp", "UpdateAppSection",
|
|
193
|
+
{"appId": app_id, "sectionId": section_id, "name": name},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def delete_section(
|
|
198
|
+
session: Session, app_id: str, section_id: str,
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
"""Delete a section from an application."""
|
|
201
|
+
return session.api_call(
|
|
202
|
+
"HomeApp", "DeleteAppSection",
|
|
203
|
+
{"appId": app_id, "sectionId": section_id},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── Backup / Export ──────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def backup_app(
|
|
211
|
+
session: Session, app_id: str, project_id: str, contain_data: bool = True,
|
|
212
|
+
) -> dict[str, Any]:
|
|
213
|
+
"""Backup an application."""
|
|
214
|
+
return session.api_call(
|
|
215
|
+
"AppManagement", "Backup",
|
|
216
|
+
{"appId": app_id, "projectId": project_id, "containData": contain_data},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def restore_app(
|
|
221
|
+
session: Session,
|
|
222
|
+
app_id: str,
|
|
223
|
+
project_id: str,
|
|
224
|
+
backup_id: str,
|
|
225
|
+
file_url: str,
|
|
226
|
+
file_name: str,
|
|
227
|
+
contain_data: bool = True,
|
|
228
|
+
is_restore_new: bool = False,
|
|
229
|
+
) -> dict[str, Any]:
|
|
230
|
+
"""Restore an application from backup."""
|
|
231
|
+
return session.api_call(
|
|
232
|
+
"AppManagement", "Restore",
|
|
233
|
+
{
|
|
234
|
+
"appId": app_id,
|
|
235
|
+
"projectId": project_id,
|
|
236
|
+
"id": backup_id,
|
|
237
|
+
"fileUrl": file_url,
|
|
238
|
+
"fileName": file_name,
|
|
239
|
+
"containData": contain_data,
|
|
240
|
+
"isRestoreNew": is_restore_new,
|
|
241
|
+
},
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def export_apps(session: Session, app_ids: list[str]) -> dict[str, Any]:
|
|
246
|
+
"""Batch export applications."""
|
|
247
|
+
return session.api_call("AppManagement", "BatchExportApp", {"appIds": app_ids})
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def get_exports(session: Session, app_id: str) -> dict[str, Any]:
|
|
251
|
+
"""Get export list for an application."""
|
|
252
|
+
return session.api_call("AppManagement", "GetExportsByApp", {"appId": app_id})
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def get_backup_logs(
|
|
256
|
+
session: Session,
|
|
257
|
+
app_id: str,
|
|
258
|
+
project_id: str,
|
|
259
|
+
page_index: int = 1,
|
|
260
|
+
page_size: int = 20,
|
|
261
|
+
) -> dict[str, Any]:
|
|
262
|
+
"""Get backup/restore operation logs."""
|
|
263
|
+
return session.api_call(
|
|
264
|
+
"AppManagement", "PageGetBackupRestoreOperationLog",
|
|
265
|
+
{
|
|
266
|
+
"appId": app_id,
|
|
267
|
+
"projectId": project_id,
|
|
268
|
+
"pageIndex": page_index,
|
|
269
|
+
"pageSize": page_size,
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ── Usage & Logs ─────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def get_usage_stats(
|
|
278
|
+
session: Session,
|
|
279
|
+
project_id: str,
|
|
280
|
+
app_id: str = "",
|
|
281
|
+
day_range: int = 7,
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
"""Get application usage statistics."""
|
|
284
|
+
data: dict[str, Any] = {"projectId": project_id, "dayRange": day_range}
|
|
285
|
+
if app_id:
|
|
286
|
+
data["appId"] = app_id
|
|
287
|
+
return session.api_call(
|
|
288
|
+
"AppManagement", "AllUsageOverviewStatistics", data,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def get_app_logs(
|
|
293
|
+
session: Session,
|
|
294
|
+
app_id: str,
|
|
295
|
+
page_index: int = 1,
|
|
296
|
+
page_size: int = 50,
|
|
297
|
+
) -> dict[str, Any]:
|
|
298
|
+
"""Get application operation logs."""
|
|
299
|
+
return session.api_call(
|
|
300
|
+
"AppManagement", "GetLogs",
|
|
301
|
+
{"appId": app_id, "pageIndex": page_index, "pageSize": page_size},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def get_app_langs(session: Session, app_id: str) -> dict[str, Any]:
|
|
306
|
+
"""Get application language list."""
|
|
307
|
+
return session.api_call("AppManagement", "GetAppLangs", {"appId": app_id})
|
hap_cli/core/auth.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Browser-based authentication flow for MingDAO HAP.
|
|
2
|
+
|
|
3
|
+
Implements a local callback server pattern:
|
|
4
|
+
1. Start local HTTP server on a free port
|
|
5
|
+
2. Open browser to HAP login page with callback URL
|
|
6
|
+
3. Capture token from redirect, save to config
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import socket
|
|
12
|
+
import threading
|
|
13
|
+
import webbrowser
|
|
14
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
15
|
+
from typing import Optional
|
|
16
|
+
from urllib.parse import urlparse, parse_qs
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from hap_cli.core.session import _main_api_base_for_url
|
|
21
|
+
|
|
22
|
+
# Known server presets: name -> (api_base, webui_url)
|
|
23
|
+
# api_base is kept for backward compatibility with resolve_server callers.
|
|
24
|
+
SERVER_PRESETS = {
|
|
25
|
+
"mingdao": ("https://www.mingdao.com/api", "https://www.mingdao.com"),
|
|
26
|
+
"nocoly": ("https://www.nocoly.com/wwwapi", "https://www.nocoly.com"),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_server(server: str) -> tuple[str, str]:
|
|
31
|
+
"""Resolve server name or URL to (api_host, webui_url).
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
server: Preset name ('mingdao', 'nocoly') or self-hosted URL.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Tuple of (api_host, webui_url).
|
|
38
|
+
"""
|
|
39
|
+
key = server.lower().strip()
|
|
40
|
+
if key in SERVER_PRESETS:
|
|
41
|
+
return SERVER_PRESETS[key]
|
|
42
|
+
|
|
43
|
+
# Self-hosted: URL is both webui base and API base
|
|
44
|
+
base = server.rstrip("/")
|
|
45
|
+
return (f"{base}/wwwapi", base)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_available_port(start: int = 5100) -> int:
|
|
49
|
+
"""Find an available TCP port starting from `start`."""
|
|
50
|
+
for port in range(start, start + 100):
|
|
51
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
52
|
+
try:
|
|
53
|
+
s.bind(("127.0.0.1", port))
|
|
54
|
+
return port
|
|
55
|
+
except OSError:
|
|
56
|
+
continue
|
|
57
|
+
raise RuntimeError("No available port found in range 5100-5199")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_auth_url(webui: str, port: int) -> str:
|
|
61
|
+
"""Build the browser auth URL with base64-encoded callback info."""
|
|
62
|
+
callback_info = json.dumps({"url": f"http://localhost:{port}"})
|
|
63
|
+
encoded = base64.b64encode(callback_info.encode()).decode()
|
|
64
|
+
return f"{webui}/cliauth?p={encoded}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _decrypt_response(encrypted_value: str, decrypt_key: str) -> dict:
|
|
68
|
+
"""Decrypt AES-CBC encrypted API response (private deployments).
|
|
69
|
+
|
|
70
|
+
Requires pycryptodome: pip install pycryptodome
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
from Crypto.Cipher import AES
|
|
74
|
+
except ImportError:
|
|
75
|
+
raise ImportError(
|
|
76
|
+
"pycryptodome is required for encrypted responses. "
|
|
77
|
+
"Install it with: pip install pycryptodome"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
IV = b"MIGfMA0GCSqGSIb3" # Fixed 16-byte IV
|
|
81
|
+
key_bytes = decrypt_key.encode("utf-8")
|
|
82
|
+
key_len = len(key_bytes)
|
|
83
|
+
required_len = 16 if key_len <= 16 else (24 if key_len <= 24 else 32)
|
|
84
|
+
padded_key = key_bytes[:required_len].ljust(required_len, b"\x00")
|
|
85
|
+
cipher = AES.new(padded_key, AES.MODE_CBC, IV)
|
|
86
|
+
ct = base64.b64decode(encrypted_value)
|
|
87
|
+
decrypted = cipher.decrypt(ct)
|
|
88
|
+
pad_len = decrypted[-1]
|
|
89
|
+
raw = decrypted[:-pad_len].decode("utf-8").replace("\t", "\\t")
|
|
90
|
+
return json.loads(raw)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_user_info(host: str, token: str) -> dict:
|
|
94
|
+
"""Verify token and get user info via GetGlobalMeta API.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
host: Login URL or API base URL (e.g. 'https://www.mingdao.com' or
|
|
98
|
+
'https://www.mingdao.com/api'). Host type is auto-detected.
|
|
99
|
+
token: Auth token.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Dict with keys: id, name, email, avatar, lang.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
requests.HTTPError: If API call fails.
|
|
106
|
+
ValueError: If response is invalid or token is rejected.
|
|
107
|
+
"""
|
|
108
|
+
url = _main_api_base_for_url(host) + "/Global/GetGlobalMeta"
|
|
109
|
+
resp = requests.post(
|
|
110
|
+
url,
|
|
111
|
+
json={},
|
|
112
|
+
headers={
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
"Authorization": f"md_pss_id {token}",
|
|
115
|
+
},
|
|
116
|
+
timeout=15,
|
|
117
|
+
)
|
|
118
|
+
resp.raise_for_status()
|
|
119
|
+
raw = resp.json()
|
|
120
|
+
|
|
121
|
+
if raw.get("state") == 0:
|
|
122
|
+
raise ValueError(raw.get("exception", "Token verification failed"))
|
|
123
|
+
|
|
124
|
+
# Handle encrypted responses (private deployments)
|
|
125
|
+
if raw.get("encrypted") and raw.get("key"):
|
|
126
|
+
data = _decrypt_response(raw["data"], raw["key"])
|
|
127
|
+
else:
|
|
128
|
+
data = raw
|
|
129
|
+
|
|
130
|
+
account = data.get("data", {}).get("md.global", {}).get("Account", {})
|
|
131
|
+
if not account:
|
|
132
|
+
raise ValueError("Unable to extract account info from response")
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
"id": account.get("accountId", ""),
|
|
136
|
+
"name": account.get("fullname", ""),
|
|
137
|
+
"email": account.get("email", ""),
|
|
138
|
+
"avatar": account.get("avatar", ""),
|
|
139
|
+
"lang": account.get("lang", ""),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def login(server: str, timeout: int = 300) -> tuple[str, str, dict]:
|
|
144
|
+
"""Run the full browser-based login flow.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
server: Preset name ('mingdao', 'nocoly') or self-hosted URL.
|
|
148
|
+
timeout: Max seconds to wait for user to complete login.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Tuple of (token, api_host, user_info).
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
TimeoutError: If user does not complete login in time.
|
|
155
|
+
"""
|
|
156
|
+
api_host, webui = resolve_server(server)
|
|
157
|
+
port = get_available_port()
|
|
158
|
+
|
|
159
|
+
received = threading.Event()
|
|
160
|
+
result: dict = {}
|
|
161
|
+
server_ref: dict = {}
|
|
162
|
+
|
|
163
|
+
class AuthCallbackHandler(BaseHTTPRequestHandler):
|
|
164
|
+
def do_OPTIONS(self):
|
|
165
|
+
self.send_response(204)
|
|
166
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
167
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
168
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
169
|
+
self.end_headers()
|
|
170
|
+
|
|
171
|
+
def do_GET(self):
|
|
172
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
173
|
+
parsed = urlparse(self.path)
|
|
174
|
+
params = parse_qs(parsed.query)
|
|
175
|
+
encoded_token = params.get("t", [None])[0]
|
|
176
|
+
|
|
177
|
+
if not encoded_token:
|
|
178
|
+
self.send_response(400)
|
|
179
|
+
self.end_headers()
|
|
180
|
+
self.wfile.write(b"No token provided")
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
token = base64.b64decode(encoded_token).decode("utf-8")
|
|
184
|
+
result["token"] = token
|
|
185
|
+
|
|
186
|
+
# Redirect browser to success page
|
|
187
|
+
self.send_response(302)
|
|
188
|
+
self.send_header("Location", f"{webui}/cliauth/success")
|
|
189
|
+
self.end_headers()
|
|
190
|
+
|
|
191
|
+
# Shut down server after responding
|
|
192
|
+
threading.Timer(
|
|
193
|
+
0.1,
|
|
194
|
+
lambda: (server_ref["srv"].shutdown(), received.set()),
|
|
195
|
+
).start()
|
|
196
|
+
|
|
197
|
+
def log_message(self, format, *args):
|
|
198
|
+
pass # Suppress HTTP server logs
|
|
199
|
+
|
|
200
|
+
srv = HTTPServer(("127.0.0.1", port), AuthCallbackHandler)
|
|
201
|
+
server_ref["srv"] = srv
|
|
202
|
+
|
|
203
|
+
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
204
|
+
thread.start()
|
|
205
|
+
|
|
206
|
+
auth_url = build_auth_url(webui, port)
|
|
207
|
+
webbrowser.open(auth_url)
|
|
208
|
+
|
|
209
|
+
received.wait(timeout=timeout)
|
|
210
|
+
|
|
211
|
+
token = result.get("token")
|
|
212
|
+
if not token:
|
|
213
|
+
raise TimeoutError("Login timed out — no token received")
|
|
214
|
+
|
|
215
|
+
# Verify token and get user info
|
|
216
|
+
user_info = get_user_info(webui, token)
|
|
217
|
+
# Return the login URL (webui), not the API base, so callers store
|
|
218
|
+
# the canonical login URL in session config.
|
|
219
|
+
return token, webui, user_info
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Calendar and schedule management for MingDAO HAP.
|
|
2
|
+
|
|
3
|
+
Named calendar_mod.py to avoid conflict with Python stdlib calendar module.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from hap_cli.core.session import Session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_calendars(
|
|
12
|
+
session: Session,
|
|
13
|
+
start_date: str,
|
|
14
|
+
end_date: str,
|
|
15
|
+
project_id: str = "",
|
|
16
|
+
) -> dict[str, Any]:
|
|
17
|
+
"""Get calendar events list."""
|
|
18
|
+
data: dict[str, Any] = {"startDate": start_date, "endDate": end_date}
|
|
19
|
+
if project_id:
|
|
20
|
+
data["projectId"] = project_id
|
|
21
|
+
return session.api_call("Calendar", "GetCalendars", data)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_calendar_detail(
|
|
25
|
+
session: Session,
|
|
26
|
+
calendar_id: str,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""Get calendar event detail."""
|
|
29
|
+
return session.api_call("Calendar", "GetCalendarDetail", {"calendarId": calendar_id})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def insert_calendar(
|
|
33
|
+
session: Session,
|
|
34
|
+
name: str,
|
|
35
|
+
start_date: str,
|
|
36
|
+
end_date: str,
|
|
37
|
+
description: str = "",
|
|
38
|
+
is_private: bool = False,
|
|
39
|
+
cat_id: str = "",
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
"""Create a new calendar event."""
|
|
42
|
+
data: dict[str, Any] = {
|
|
43
|
+
"name": name,
|
|
44
|
+
"startDate": start_date,
|
|
45
|
+
"endDate": end_date,
|
|
46
|
+
"isPrivate": is_private,
|
|
47
|
+
}
|
|
48
|
+
if description:
|
|
49
|
+
data["description"] = description
|
|
50
|
+
if cat_id:
|
|
51
|
+
data["catId"] = cat_id
|
|
52
|
+
return session.api_call("Calendar", "InsertCalendar", data)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def edit_calendar(
|
|
56
|
+
session: Session,
|
|
57
|
+
calendar_id: str,
|
|
58
|
+
name: str = "",
|
|
59
|
+
start_date: str = "",
|
|
60
|
+
end_date: str = "",
|
|
61
|
+
description: str = "",
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
"""Edit a calendar event."""
|
|
64
|
+
data: dict[str, Any] = {"calendarId": calendar_id}
|
|
65
|
+
if name:
|
|
66
|
+
data["name"] = name
|
|
67
|
+
if start_date:
|
|
68
|
+
data["startDate"] = start_date
|
|
69
|
+
if end_date:
|
|
70
|
+
data["endDate"] = end_date
|
|
71
|
+
if description:
|
|
72
|
+
data["description"] = description
|
|
73
|
+
return session.api_call("Calendar", "EditCalendar", data)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def delete_calendar(
|
|
77
|
+
session: Session,
|
|
78
|
+
calendar_id: str,
|
|
79
|
+
recur_time: str = "",
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""Delete a calendar event."""
|
|
82
|
+
data: dict[str, Any] = {"calendarId": calendar_id}
|
|
83
|
+
if recur_time:
|
|
84
|
+
data["recurTime"] = recur_time
|
|
85
|
+
return session.api_call("Calendar", "DeleteCalendar", data)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def add_members(
|
|
89
|
+
session: Session,
|
|
90
|
+
calendar_id: str,
|
|
91
|
+
member_ids: list[str],
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
"""Add members to a calendar event."""
|
|
94
|
+
return session.api_call(
|
|
95
|
+
"Calendar", "AddMembers",
|
|
96
|
+
{"calendarId": calendar_id, "memberIds": member_ids},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def remove_member(
|
|
101
|
+
session: Session,
|
|
102
|
+
calendar_id: str,
|
|
103
|
+
account_id: str,
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
"""Remove a member from a calendar event."""
|
|
106
|
+
return session.api_call(
|
|
107
|
+
"Calendar", "RemoveMember",
|
|
108
|
+
{"calendarId": calendar_id, "accountId": account_id},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_categories(session: Session) -> dict[str, Any]:
|
|
113
|
+
"""Get all calendar categories."""
|
|
114
|
+
return session.api_call("Calendar", "GetUserAllCalCategories", {})
|