@josephyan/qingflow-cli 0.2.0-beta.55
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.
- package/README.md +30 -0
- package/docs/local-agent-install.md +235 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow.mjs +5 -0
- package/npm/lib/runtime.mjs +204 -0
- package/npm/scripts/postinstall.mjs +16 -0
- package/package.json +34 -0
- package/pyproject.toml +67 -0
- package/qingflow +15 -0
- package/src/qingflow_mcp/__init__.py +5 -0
- package/src/qingflow_mcp/__main__.py +5 -0
- package/src/qingflow_mcp/backend_client.py +547 -0
- package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
- package/src/qingflow_mcp/builder_facade/models.py +985 -0
- package/src/qingflow_mcp/builder_facade/service.py +8243 -0
- package/src/qingflow_mcp/cli/__init__.py +1 -0
- package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
- package/src/qingflow_mcp/cli/commands/app.py +40 -0
- package/src/qingflow_mcp/cli/commands/auth.py +78 -0
- package/src/qingflow_mcp/cli/commands/builder.py +184 -0
- package/src/qingflow_mcp/cli/commands/common.py +47 -0
- package/src/qingflow_mcp/cli/commands/imports.py +86 -0
- package/src/qingflow_mcp/cli/commands/record.py +202 -0
- package/src/qingflow_mcp/cli/commands/task.py +87 -0
- package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
- package/src/qingflow_mcp/cli/context.py +48 -0
- package/src/qingflow_mcp/cli/formatters.py +269 -0
- package/src/qingflow_mcp/cli/json_io.py +50 -0
- package/src/qingflow_mcp/cli/main.py +147 -0
- package/src/qingflow_mcp/config.py +221 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/list_type_labels.py +76 -0
- package/src/qingflow_mcp/server.py +211 -0
- package/src/qingflow_mcp/server_app_builder.py +387 -0
- package/src/qingflow_mcp/server_app_user.py +317 -0
- package/src/qingflow_mcp/session_store.py +289 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
- package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
- package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
- package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
- package/src/qingflow_mcp/solution/design_session.py +222 -0
- package/src/qingflow_mcp/solution/design_store.py +100 -0
- package/src/qingflow_mcp/solution/executor.py +2339 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
- package/src/qingflow_mcp/solution/run_store.py +244 -0
- package/src/qingflow_mcp/solution/spec_models.py +853 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
- package/src/qingflow_mcp/tools/app_tools.py +850 -0
- package/src/qingflow_mcp/tools/approval_tools.py +833 -0
- package/src/qingflow_mcp/tools/auth_tools.py +697 -0
- package/src/qingflow_mcp/tools/base.py +81 -0
- package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
- package/src/qingflow_mcp/tools/directory_tools.py +648 -0
- package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
- package/src/qingflow_mcp/tools/file_tools.py +385 -0
- package/src/qingflow_mcp/tools/import_tools.py +1971 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
- package/src/qingflow_mcp/tools/package_tools.py +240 -0
- package/src/qingflow_mcp/tools/portal_tools.py +131 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
- package/src/qingflow_mcp/tools/record_tools.py +12739 -0
- package/src/qingflow_mcp/tools/role_tools.py +94 -0
- package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
- package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
- package/src/qingflow_mcp/tools/task_tools.py +843 -0
- package/src/qingflow_mcp/tools/view_tools.py +280 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
- package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
from ..backend_client import BackendClient
|
|
8
|
+
from ..config import get_feedback_app_key, get_feedback_base_url, get_feedback_qsource_token, normalize_base_url
|
|
9
|
+
from ..errors import QingflowApiError, raise_tool_error
|
|
10
|
+
from ..json_types import JSONObject
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CATEGORY_MAP = {
|
|
14
|
+
"feature_request": "功能需求",
|
|
15
|
+
"bug_report": "问题反馈",
|
|
16
|
+
"ux_feedback": "体验建议",
|
|
17
|
+
"unsupported_scenario": "不支持场景",
|
|
18
|
+
"other": "其他",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
IMPACT_SCOPE_MAP = {
|
|
22
|
+
"personal": "仅个人",
|
|
23
|
+
"small_team": "小范围团队",
|
|
24
|
+
"cross_team": "跨团队",
|
|
25
|
+
"global": "全局",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class FeedbackTools:
|
|
31
|
+
backend: BackendClient
|
|
32
|
+
mcp_side: str
|
|
33
|
+
|
|
34
|
+
def register(self, mcp: FastMCP) -> None:
|
|
35
|
+
@mcp.tool()
|
|
36
|
+
def feedback_submit(
|
|
37
|
+
category: str = "",
|
|
38
|
+
title: str = "",
|
|
39
|
+
description: str = "",
|
|
40
|
+
expected_behavior: str | None = None,
|
|
41
|
+
actual_behavior: str | None = None,
|
|
42
|
+
impact_scope: str | None = None,
|
|
43
|
+
tool_name: str | None = None,
|
|
44
|
+
app_key: str | None = None,
|
|
45
|
+
record_id: str | int | None = None,
|
|
46
|
+
workflow_node_id: str | int | None = None,
|
|
47
|
+
note: str | None = None,
|
|
48
|
+
) -> JSONObject:
|
|
49
|
+
"""Submit product feedback to the Qingflow MCP team.
|
|
50
|
+
|
|
51
|
+
Use this when the current MCP capability is unsupported, awkward, or still cannot satisfy the user's need
|
|
52
|
+
after reasonable attempts. This helper writes through the internal q-source feedback intake, does not
|
|
53
|
+
require Qingflow login or workspace selection, and should be called only after explicit user confirmation.
|
|
54
|
+
"""
|
|
55
|
+
return self.feedback_submit(
|
|
56
|
+
category=category,
|
|
57
|
+
title=title,
|
|
58
|
+
description=description,
|
|
59
|
+
expected_behavior=expected_behavior,
|
|
60
|
+
actual_behavior=actual_behavior,
|
|
61
|
+
impact_scope=impact_scope,
|
|
62
|
+
tool_name=tool_name,
|
|
63
|
+
app_key=app_key,
|
|
64
|
+
record_id=record_id,
|
|
65
|
+
workflow_node_id=workflow_node_id,
|
|
66
|
+
note=note,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def feedback_submit(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
category: str,
|
|
73
|
+
title: str,
|
|
74
|
+
description: str,
|
|
75
|
+
expected_behavior: str | None,
|
|
76
|
+
actual_behavior: str | None,
|
|
77
|
+
impact_scope: str | None,
|
|
78
|
+
tool_name: str | None,
|
|
79
|
+
app_key: str | None,
|
|
80
|
+
record_id: str | int | None,
|
|
81
|
+
workflow_node_id: str | int | None,
|
|
82
|
+
note: str | None,
|
|
83
|
+
) -> JSONObject:
|
|
84
|
+
qsource_token = get_feedback_qsource_token()
|
|
85
|
+
if not qsource_token:
|
|
86
|
+
raise_tool_error(
|
|
87
|
+
QingflowApiError(
|
|
88
|
+
category="config",
|
|
89
|
+
message=(
|
|
90
|
+
"feedback_submit is not configured. Set "
|
|
91
|
+
"feedback.qsource_token or QINGFLOW_MCP_FEEDBACK_QSOURCE_TOKEN first."
|
|
92
|
+
),
|
|
93
|
+
details={"error_code": "FEEDBACK_NOT_CONFIGURED"},
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
base_url = get_feedback_base_url()
|
|
98
|
+
if not base_url:
|
|
99
|
+
raise_tool_error(
|
|
100
|
+
QingflowApiError.config_error(
|
|
101
|
+
"feedback_submit requires a base_url. Configure feedback.base_url or default_base_url."
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
normalized_payload = self._build_payload(
|
|
106
|
+
category=category,
|
|
107
|
+
title=title,
|
|
108
|
+
description=description,
|
|
109
|
+
expected_behavior=expected_behavior,
|
|
110
|
+
actual_behavior=actual_behavior,
|
|
111
|
+
impact_scope=impact_scope,
|
|
112
|
+
tool_name=tool_name,
|
|
113
|
+
app_key=app_key,
|
|
114
|
+
record_id=record_id,
|
|
115
|
+
workflow_node_id=workflow_node_id,
|
|
116
|
+
note=note,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
response = self.backend.public_request_with_meta(
|
|
121
|
+
"POST",
|
|
122
|
+
base_url,
|
|
123
|
+
f"/qsource/{qsource_token}",
|
|
124
|
+
json_body=normalized_payload,
|
|
125
|
+
unwrap=True,
|
|
126
|
+
qf_version=None,
|
|
127
|
+
)
|
|
128
|
+
except QingflowApiError as exc:
|
|
129
|
+
raise_tool_error(exc)
|
|
130
|
+
result = response.data if isinstance(response.data, dict) else {}
|
|
131
|
+
feedback_request_id = result.get("requestId") if isinstance(result, dict) else None
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"ok": True,
|
|
135
|
+
"request_route": {
|
|
136
|
+
"base_url": normalize_base_url(base_url) or base_url,
|
|
137
|
+
"qf_version": None,
|
|
138
|
+
"qf_version_source": "not_applicable",
|
|
139
|
+
},
|
|
140
|
+
"submission_mode": "qsource_passive",
|
|
141
|
+
"feedback_target": {
|
|
142
|
+
"app_key": get_feedback_app_key(),
|
|
143
|
+
"mcp_side": self.mcp_side,
|
|
144
|
+
},
|
|
145
|
+
"normalized_payload": normalized_payload,
|
|
146
|
+
"feedback_request_id": feedback_request_id,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def _build_payload(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
category: str,
|
|
153
|
+
title: str,
|
|
154
|
+
description: str,
|
|
155
|
+
expected_behavior: str | None,
|
|
156
|
+
actual_behavior: str | None,
|
|
157
|
+
impact_scope: str | None,
|
|
158
|
+
tool_name: str | None,
|
|
159
|
+
app_key: str | None,
|
|
160
|
+
record_id: str | int | None,
|
|
161
|
+
workflow_node_id: str | int | None,
|
|
162
|
+
note: str | None,
|
|
163
|
+
) -> JSONObject:
|
|
164
|
+
payload: JSONObject = {
|
|
165
|
+
"title": self._require_text("title", title),
|
|
166
|
+
"category": self._normalize_label("category", category, CATEGORY_MAP, required=True),
|
|
167
|
+
"description": self._require_text("description", description),
|
|
168
|
+
"submit_method": "AI代提",
|
|
169
|
+
"status": "待处理",
|
|
170
|
+
"mcp_side": self.mcp_side,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
optional_text = {
|
|
174
|
+
"expected_result": expected_behavior,
|
|
175
|
+
"actual_behavior": actual_behavior,
|
|
176
|
+
"tool_name": tool_name,
|
|
177
|
+
"app_key": app_key,
|
|
178
|
+
"note": note,
|
|
179
|
+
}
|
|
180
|
+
for key, value in optional_text.items():
|
|
181
|
+
normalized = self._normalize_optional_text(value)
|
|
182
|
+
if normalized is not None:
|
|
183
|
+
payload[key] = normalized
|
|
184
|
+
|
|
185
|
+
if impact_scope is not None and str(impact_scope).strip():
|
|
186
|
+
payload["impact_scope"] = self._normalize_label("impact_scope", impact_scope, IMPACT_SCOPE_MAP, required=False)
|
|
187
|
+
|
|
188
|
+
if record_id is not None and str(record_id).strip():
|
|
189
|
+
payload["record_id"] = str(record_id).strip()
|
|
190
|
+
if workflow_node_id is not None and str(workflow_node_id).strip():
|
|
191
|
+
payload["workflow_node_id"] = str(workflow_node_id).strip()
|
|
192
|
+
|
|
193
|
+
return payload
|
|
194
|
+
|
|
195
|
+
def _normalize_label(self, field: str, value: str, mapping: dict[str, str], *, required: bool) -> str:
|
|
196
|
+
text = str(value or "").strip()
|
|
197
|
+
if not text:
|
|
198
|
+
if required:
|
|
199
|
+
raise_tool_error(QingflowApiError.config_error(f"{field} is required"))
|
|
200
|
+
return ""
|
|
201
|
+
|
|
202
|
+
canonical = text.lower()
|
|
203
|
+
if canonical in mapping:
|
|
204
|
+
return mapping[canonical]
|
|
205
|
+
if text in mapping.values():
|
|
206
|
+
return text
|
|
207
|
+
supported_values = list(mapping.keys()) + list(mapping.values())
|
|
208
|
+
raise_tool_error(
|
|
209
|
+
QingflowApiError(
|
|
210
|
+
category="config",
|
|
211
|
+
message=f"{field} must be one of the supported canonical values or labels",
|
|
212
|
+
details={
|
|
213
|
+
"error_code": "FEEDBACK_INVALID_INPUT",
|
|
214
|
+
"field": field,
|
|
215
|
+
"supported_values": supported_values,
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def _require_text(self, field: str, value: str) -> str:
|
|
221
|
+
normalized = str(value or "").strip()
|
|
222
|
+
if not normalized:
|
|
223
|
+
raise_tool_error(QingflowApiError.config_error(f"{field} is required"))
|
|
224
|
+
return normalized
|
|
225
|
+
|
|
226
|
+
def _normalize_optional_text(self, value: str | None) -> str | None:
|
|
227
|
+
if value is None:
|
|
228
|
+
return None
|
|
229
|
+
normalized = str(value).strip()
|
|
230
|
+
return normalized or None
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import mimetypes
|
|
6
|
+
import random
|
|
7
|
+
import string
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from mcp.server.fastmcp import FastMCP
|
|
13
|
+
|
|
14
|
+
from ..config import DEFAULT_PROFILE
|
|
15
|
+
from ..errors import QingflowApiError, raise_tool_error
|
|
16
|
+
from ..json_types import JSONObject
|
|
17
|
+
from .base import ToolBase
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ATTACHMENT_UPLOAD_INFO_FALLBACK_CODES = {"40118", 40118}
|
|
21
|
+
LEGACY_OSS_FORM_REQUIRED_KEYS = ("key", "policy", "signature", "ossAccessKeyId")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FileTools(ToolBase):
|
|
25
|
+
def register(self, mcp: FastMCP) -> None:
|
|
26
|
+
@mcp.tool()
|
|
27
|
+
def file_get_upload_info(
|
|
28
|
+
profile: str = DEFAULT_PROFILE,
|
|
29
|
+
upload_kind: str = "attachment",
|
|
30
|
+
file_name: str = "",
|
|
31
|
+
file_size: int = 0,
|
|
32
|
+
upload_mark: str | None = None,
|
|
33
|
+
content_type: str | None = None,
|
|
34
|
+
bucket_type: str | None = None,
|
|
35
|
+
path_id: int | None = None,
|
|
36
|
+
file_related_url: str | None = None,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
return self.file_get_upload_info(
|
|
39
|
+
profile=profile,
|
|
40
|
+
upload_kind=upload_kind,
|
|
41
|
+
file_name=file_name,
|
|
42
|
+
file_size=file_size,
|
|
43
|
+
upload_mark=upload_mark,
|
|
44
|
+
content_type=content_type,
|
|
45
|
+
bucket_type=bucket_type,
|
|
46
|
+
path_id=path_id,
|
|
47
|
+
file_related_url=file_related_url,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@mcp.tool()
|
|
51
|
+
def file_upload_local(
|
|
52
|
+
profile: str = DEFAULT_PROFILE,
|
|
53
|
+
upload_kind: str = "attachment",
|
|
54
|
+
file_path: str = "",
|
|
55
|
+
upload_mark: str | None = None,
|
|
56
|
+
content_type: str | None = None,
|
|
57
|
+
bucket_type: str | None = None,
|
|
58
|
+
path_id: int | None = None,
|
|
59
|
+
file_related_url: str | None = None,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
return self.file_upload_local(
|
|
62
|
+
profile=profile,
|
|
63
|
+
upload_kind=upload_kind,
|
|
64
|
+
file_path=file_path,
|
|
65
|
+
upload_mark=upload_mark,
|
|
66
|
+
content_type=content_type,
|
|
67
|
+
bucket_type=bucket_type,
|
|
68
|
+
path_id=path_id,
|
|
69
|
+
file_related_url=file_related_url,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def file_get_upload_info(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
profile: str,
|
|
76
|
+
upload_kind: str,
|
|
77
|
+
file_name: str,
|
|
78
|
+
file_size: int,
|
|
79
|
+
upload_mark: str | None = None,
|
|
80
|
+
content_type: str | None = None,
|
|
81
|
+
bucket_type: str | None = None,
|
|
82
|
+
path_id: int | None = None,
|
|
83
|
+
file_related_url: str | None = None,
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
def runner(session_profile, context):
|
|
86
|
+
upload_info = self._request_upload_info_with_fallback(
|
|
87
|
+
context,
|
|
88
|
+
upload_kind=upload_kind,
|
|
89
|
+
file_name=file_name,
|
|
90
|
+
file_size=file_size,
|
|
91
|
+
upload_mark=upload_mark,
|
|
92
|
+
content_type=content_type,
|
|
93
|
+
bucket_type=bucket_type,
|
|
94
|
+
path_id=path_id,
|
|
95
|
+
file_related_url=file_related_url,
|
|
96
|
+
)
|
|
97
|
+
return {
|
|
98
|
+
"profile": profile,
|
|
99
|
+
"ws_id": session_profile.selected_ws_id,
|
|
100
|
+
"upload_kind": upload_kind,
|
|
101
|
+
"requested_upload_kind": upload_info["requested_upload_kind"],
|
|
102
|
+
"effective_upload_kind": upload_info["effective_upload_kind"],
|
|
103
|
+
"upload_fallback_applied": upload_info["fallback_applied"],
|
|
104
|
+
"upload_fallback_reason": upload_info["fallback_reason"],
|
|
105
|
+
"file_name": file_name,
|
|
106
|
+
"file_size": file_size,
|
|
107
|
+
"request_route": self.backend.describe_route(context),
|
|
108
|
+
"result": upload_info["result"],
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return self._run(profile, runner)
|
|
112
|
+
|
|
113
|
+
def file_upload_local(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
profile: str,
|
|
117
|
+
upload_kind: str,
|
|
118
|
+
file_path: str,
|
|
119
|
+
upload_mark: str | None = None,
|
|
120
|
+
content_type: str | None = None,
|
|
121
|
+
bucket_type: str | None = None,
|
|
122
|
+
path_id: int | None = None,
|
|
123
|
+
file_related_url: str | None = None,
|
|
124
|
+
) -> dict[str, Any]:
|
|
125
|
+
path = Path(file_path).expanduser()
|
|
126
|
+
if not path.is_file():
|
|
127
|
+
raise_tool_error(QingflowApiError.config_error("file_path must point to an existing file"))
|
|
128
|
+
file_name = path.name
|
|
129
|
+
file_size = path.stat().st_size
|
|
130
|
+
resolved_content_type = content_type or mimetypes.guess_type(file_name)[0] or "application/octet-stream"
|
|
131
|
+
|
|
132
|
+
def runner(session_profile, context):
|
|
133
|
+
upload_info = self._request_upload_info_with_fallback(
|
|
134
|
+
context,
|
|
135
|
+
upload_kind=upload_kind,
|
|
136
|
+
file_name=file_name,
|
|
137
|
+
file_size=file_size,
|
|
138
|
+
upload_mark=upload_mark,
|
|
139
|
+
content_type=resolved_content_type,
|
|
140
|
+
bucket_type=bucket_type,
|
|
141
|
+
path_id=path_id,
|
|
142
|
+
file_related_url=file_related_url,
|
|
143
|
+
)
|
|
144
|
+
result = upload_info["result"]
|
|
145
|
+
if not isinstance(result, dict):
|
|
146
|
+
raise QingflowApiError.config_error("upload endpoint did not return a structured upload payload")
|
|
147
|
+
content = path.read_bytes()
|
|
148
|
+
upload_protocol = "binary_put"
|
|
149
|
+
if self._is_legacy_oss_form_upload(result):
|
|
150
|
+
upload_result = self._upload_legacy_oss_form(
|
|
151
|
+
result,
|
|
152
|
+
file_name=file_name,
|
|
153
|
+
content=content,
|
|
154
|
+
content_type=resolved_content_type,
|
|
155
|
+
)
|
|
156
|
+
upload_protocol = "oss_form_post"
|
|
157
|
+
else:
|
|
158
|
+
upload_url = str(result.get("uploadUrl") or "").strip()
|
|
159
|
+
if not upload_url:
|
|
160
|
+
raise QingflowApiError.config_error("upload endpoint did not return uploadUrl")
|
|
161
|
+
upload_result = self.backend.upload_binary(upload_url, content, content_type=resolved_content_type)
|
|
162
|
+
download_url = result.get("downloadUrl")
|
|
163
|
+
return {
|
|
164
|
+
"profile": profile,
|
|
165
|
+
"ws_id": session_profile.selected_ws_id,
|
|
166
|
+
"upload_kind": upload_kind,
|
|
167
|
+
"requested_upload_kind": upload_info["requested_upload_kind"],
|
|
168
|
+
"effective_upload_kind": upload_info["effective_upload_kind"],
|
|
169
|
+
"upload_fallback_applied": upload_info["fallback_applied"],
|
|
170
|
+
"upload_fallback_reason": upload_info["fallback_reason"],
|
|
171
|
+
"file_name": file_name,
|
|
172
|
+
"file_size": file_size,
|
|
173
|
+
"content_type": resolved_content_type,
|
|
174
|
+
"request_route": self.backend.describe_route(context),
|
|
175
|
+
"upload_protocol": upload_protocol,
|
|
176
|
+
"result": result,
|
|
177
|
+
"upload_result": upload_result,
|
|
178
|
+
"download_url": download_url,
|
|
179
|
+
"attachment_value": {
|
|
180
|
+
"value": download_url,
|
|
181
|
+
"otherInfo": file_name,
|
|
182
|
+
"name": file_name,
|
|
183
|
+
},
|
|
184
|
+
"comment_file_info": {
|
|
185
|
+
"url": download_url,
|
|
186
|
+
"name": file_name,
|
|
187
|
+
"uploadFileSize": file_size,
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return self._run(profile, runner)
|
|
192
|
+
|
|
193
|
+
def _build_file_upload_bo(
|
|
194
|
+
self,
|
|
195
|
+
*,
|
|
196
|
+
upload_kind: str,
|
|
197
|
+
file_name: str,
|
|
198
|
+
file_size: int,
|
|
199
|
+
upload_mark: str | None,
|
|
200
|
+
content_type: str | None,
|
|
201
|
+
bucket_type: str | None,
|
|
202
|
+
path_id: int | None,
|
|
203
|
+
file_related_url: str | None,
|
|
204
|
+
) -> dict[str, Any]:
|
|
205
|
+
if not file_name:
|
|
206
|
+
raise_tool_error(QingflowApiError.config_error("file_name is required"))
|
|
207
|
+
if file_size <= 0:
|
|
208
|
+
raise_tool_error(QingflowApiError.config_error("file_size must be positive"))
|
|
209
|
+
if upload_kind == "attachment" and not upload_mark:
|
|
210
|
+
raise_tool_error(QingflowApiError.config_error("upload_mark is required for attachment uploads and should usually be the app_key"))
|
|
211
|
+
payload: dict[str, Any] = {
|
|
212
|
+
"fileName": file_name,
|
|
213
|
+
"fileSize": file_size,
|
|
214
|
+
}
|
|
215
|
+
if upload_mark:
|
|
216
|
+
payload["uploadMark"] = upload_mark
|
|
217
|
+
if content_type:
|
|
218
|
+
payload["contentType"] = content_type
|
|
219
|
+
if bucket_type:
|
|
220
|
+
payload["bucketType"] = bucket_type
|
|
221
|
+
if path_id is not None:
|
|
222
|
+
payload["pathId"] = path_id
|
|
223
|
+
if file_related_url:
|
|
224
|
+
payload["fileRelatedUrl"] = file_related_url
|
|
225
|
+
return payload
|
|
226
|
+
|
|
227
|
+
def _resolve_upload_endpoint(self, upload_kind: str) -> str:
|
|
228
|
+
normalized = upload_kind.strip().lower()
|
|
229
|
+
if normalized == "attachment":
|
|
230
|
+
return "/upload/puburl"
|
|
231
|
+
if normalized == "login":
|
|
232
|
+
return "/upload/url"
|
|
233
|
+
if normalized == "anonymous":
|
|
234
|
+
return "/upload/anonymousurl"
|
|
235
|
+
raise_tool_error(QingflowApiError.config_error("upload_kind must be one of: attachment, login, anonymous"))
|
|
236
|
+
raise AssertionError("unreachable")
|
|
237
|
+
|
|
238
|
+
def _encode_formula(self, formula: str) -> str:
|
|
239
|
+
encoded = base64.b64encode(quote(formula, safe="").encode("utf-8")).decode("utf-8")
|
|
240
|
+
return f"{self._random_string(16)}{encoded}{self._random_string(16)}"
|
|
241
|
+
|
|
242
|
+
def _random_string(self, length: int) -> str:
|
|
243
|
+
alphabet = string.ascii_letters + string.digits
|
|
244
|
+
return "".join(random.choice(alphabet) for _ in range(length))
|
|
245
|
+
|
|
246
|
+
def _request_upload_info_with_fallback(
|
|
247
|
+
self,
|
|
248
|
+
context,
|
|
249
|
+
*,
|
|
250
|
+
upload_kind: str,
|
|
251
|
+
file_name: str,
|
|
252
|
+
file_size: int,
|
|
253
|
+
upload_mark: str | None,
|
|
254
|
+
content_type: str | None,
|
|
255
|
+
bucket_type: str | None,
|
|
256
|
+
path_id: int | None,
|
|
257
|
+
file_related_url: str | None,
|
|
258
|
+
) -> dict[str, Any]:
|
|
259
|
+
requested_kind = upload_kind.strip().lower()
|
|
260
|
+
attempted_kinds = [requested_kind]
|
|
261
|
+
if requested_kind == "attachment":
|
|
262
|
+
attempted_kinds.append("login")
|
|
263
|
+
last_error: QingflowApiError | None = None
|
|
264
|
+
for current_kind in attempted_kinds:
|
|
265
|
+
try:
|
|
266
|
+
result = self._request_upload_info(
|
|
267
|
+
context,
|
|
268
|
+
upload_kind=current_kind,
|
|
269
|
+
file_name=file_name,
|
|
270
|
+
file_size=file_size,
|
|
271
|
+
upload_mark=upload_mark,
|
|
272
|
+
content_type=content_type,
|
|
273
|
+
bucket_type=bucket_type,
|
|
274
|
+
path_id=path_id,
|
|
275
|
+
file_related_url=file_related_url,
|
|
276
|
+
)
|
|
277
|
+
return {
|
|
278
|
+
"requested_upload_kind": requested_kind,
|
|
279
|
+
"effective_upload_kind": current_kind,
|
|
280
|
+
"fallback_applied": current_kind != requested_kind,
|
|
281
|
+
"fallback_reason": (
|
|
282
|
+
f"backend rejected '{requested_kind}' upload info; retried with '{current_kind}'"
|
|
283
|
+
if current_kind != requested_kind
|
|
284
|
+
else None
|
|
285
|
+
),
|
|
286
|
+
"result": result,
|
|
287
|
+
}
|
|
288
|
+
except QingflowApiError as error:
|
|
289
|
+
last_error = error
|
|
290
|
+
if not self._should_retry_attachment_upload_info(requested_kind, current_kind, error):
|
|
291
|
+
raise
|
|
292
|
+
assert last_error is not None
|
|
293
|
+
raise last_error
|
|
294
|
+
|
|
295
|
+
def _request_upload_info(
|
|
296
|
+
self,
|
|
297
|
+
context,
|
|
298
|
+
*,
|
|
299
|
+
upload_kind: str,
|
|
300
|
+
file_name: str,
|
|
301
|
+
file_size: int,
|
|
302
|
+
upload_mark: str | None,
|
|
303
|
+
content_type: str | None,
|
|
304
|
+
bucket_type: str | None,
|
|
305
|
+
path_id: int | None,
|
|
306
|
+
file_related_url: str | None,
|
|
307
|
+
) -> JSONObject:
|
|
308
|
+
endpoint = self._resolve_upload_endpoint(upload_kind)
|
|
309
|
+
file_upload_bo = self._build_file_upload_bo(
|
|
310
|
+
upload_kind=upload_kind,
|
|
311
|
+
file_name=file_name,
|
|
312
|
+
file_size=file_size,
|
|
313
|
+
upload_mark=upload_mark,
|
|
314
|
+
content_type=content_type,
|
|
315
|
+
bucket_type=bucket_type,
|
|
316
|
+
path_id=path_id,
|
|
317
|
+
file_related_url=file_related_url,
|
|
318
|
+
)
|
|
319
|
+
encrypted_payload = {
|
|
320
|
+
"upload": self._encode_formula(json.dumps(file_upload_bo, ensure_ascii=False, separators=(",", ":"))),
|
|
321
|
+
"fileName": file_name,
|
|
322
|
+
"fileSize": file_size,
|
|
323
|
+
}
|
|
324
|
+
if path_id is not None:
|
|
325
|
+
encrypted_payload["pathId"] = path_id
|
|
326
|
+
if bucket_type:
|
|
327
|
+
encrypted_payload["bucketType"] = bucket_type
|
|
328
|
+
result = self.backend.request("POST", context, endpoint, json_body=encrypted_payload)
|
|
329
|
+
if not isinstance(result, dict):
|
|
330
|
+
raise QingflowApiError.config_error("upload endpoint did not return a structured upload payload")
|
|
331
|
+
return result
|
|
332
|
+
|
|
333
|
+
def _should_retry_attachment_upload_info(
|
|
334
|
+
self,
|
|
335
|
+
requested_kind: str,
|
|
336
|
+
attempted_kind: str,
|
|
337
|
+
error: QingflowApiError,
|
|
338
|
+
) -> bool:
|
|
339
|
+
return (
|
|
340
|
+
requested_kind == "attachment"
|
|
341
|
+
and attempted_kind == "attachment"
|
|
342
|
+
and error.backend_code in ATTACHMENT_UPLOAD_INFO_FALLBACK_CODES
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def _is_legacy_oss_form_upload(self, payload: JSONObject) -> bool:
|
|
346
|
+
return all(str(payload.get(key) or "").strip() for key in LEGACY_OSS_FORM_REQUIRED_KEYS)
|
|
347
|
+
|
|
348
|
+
def _upload_legacy_oss_form(
|
|
349
|
+
self,
|
|
350
|
+
upload_info: JSONObject,
|
|
351
|
+
*,
|
|
352
|
+
file_name: str,
|
|
353
|
+
content: bytes,
|
|
354
|
+
content_type: str,
|
|
355
|
+
) -> dict[str, Any]:
|
|
356
|
+
upload_url = self._resolve_legacy_oss_form_url(upload_info)
|
|
357
|
+
if not upload_url:
|
|
358
|
+
raise QingflowApiError.config_error("legacy upload payload is missing host/uploadAccelerateHost")
|
|
359
|
+
form_fields = {
|
|
360
|
+
"key": str(upload_info["key"]),
|
|
361
|
+
"policy": str(upload_info["policy"]),
|
|
362
|
+
"signature": str(upload_info["signature"]),
|
|
363
|
+
"OSSAccessKeyId": str(upload_info["ossAccessKeyId"]),
|
|
364
|
+
}
|
|
365
|
+
callback = str(upload_info.get("callback") or "").strip()
|
|
366
|
+
if callback:
|
|
367
|
+
form_fields["callback"] = callback
|
|
368
|
+
return self.backend.upload_form_file(
|
|
369
|
+
upload_url,
|
|
370
|
+
form_fields=form_fields,
|
|
371
|
+
file_field="file",
|
|
372
|
+
file_name=file_name,
|
|
373
|
+
content=content,
|
|
374
|
+
content_type=content_type,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def _resolve_legacy_oss_form_url(self, upload_info: JSONObject) -> str:
|
|
378
|
+
for key in ("uploadAccelerateHost", "host", "uploadUrl"):
|
|
379
|
+
value = str(upload_info.get(key) or "").strip()
|
|
380
|
+
if not value:
|
|
381
|
+
continue
|
|
382
|
+
if value.startswith(("http://", "https://")):
|
|
383
|
+
return value
|
|
384
|
+
return f"https://{value.lstrip('/')}"
|
|
385
|
+
return ""
|