@josephyan/qingflow-cli 0.2.0-beta.1000

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 (92) hide show
  1. package/README.md +31 -0
  2. package/docs/local-agent-install.md +309 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +346 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +37 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +649 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +1846 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +16502 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +112 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +539 -0
  21. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  22. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  23. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  24. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  25. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  26. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  27. package/src/qingflow_mcp/cli/commands/task.py +141 -0
  28. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  29. package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
  30. package/src/qingflow_mcp/cli/context.py +60 -0
  31. package/src/qingflow_mcp/cli/formatters.py +573 -0
  32. package/src/qingflow_mcp/cli/json_io.py +50 -0
  33. package/src/qingflow_mcp/cli/main.py +186 -0
  34. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  35. package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
  36. package/src/qingflow_mcp/config.py +407 -0
  37. package/src/qingflow_mcp/errors.py +66 -0
  38. package/src/qingflow_mcp/id_utils.py +49 -0
  39. package/src/qingflow_mcp/import_store.py +121 -0
  40. package/src/qingflow_mcp/json_types.py +18 -0
  41. package/src/qingflow_mcp/list_type_labels.py +76 -0
  42. package/src/qingflow_mcp/public_surface.py +243 -0
  43. package/src/qingflow_mcp/repository_store.py +71 -0
  44. package/src/qingflow_mcp/response_trim.py +841 -0
  45. package/src/qingflow_mcp/server.py +216 -0
  46. package/src/qingflow_mcp/server_app_builder.py +543 -0
  47. package/src/qingflow_mcp/server_app_user.py +386 -0
  48. package/src/qingflow_mcp/session_store.py +369 -0
  49. package/src/qingflow_mcp/solution/__init__.py +6 -0
  50. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  51. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  52. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  53. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  54. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  55. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  56. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  57. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  58. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  59. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  60. package/src/qingflow_mcp/solution/design_session.py +222 -0
  61. package/src/qingflow_mcp/solution/design_store.py +100 -0
  62. package/src/qingflow_mcp/solution/executor.py +2398 -0
  63. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  64. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  65. package/src/qingflow_mcp/solution/run_store.py +244 -0
  66. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  67. package/src/qingflow_mcp/tools/__init__.py +1 -0
  68. package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
  69. package/src/qingflow_mcp/tools/app_tools.py +926 -0
  70. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  71. package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
  72. package/src/qingflow_mcp/tools/base.py +281 -0
  73. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  74. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  75. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  76. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  77. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  78. package/src/qingflow_mcp/tools/import_tools.py +2223 -0
  79. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  80. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  81. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  82. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  83. package/src/qingflow_mcp/tools/record_tools.py +14291 -0
  84. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  85. package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
  86. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  87. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  88. package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
  89. package/src/qingflow_mcp/tools/task_tools.py +889 -0
  90. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  91. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  92. package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
@@ -0,0 +1,675 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from typing import Any
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ from ..config import DEFAULT_PROFILE
9
+ from ..errors import QingflowApiError, raise_tool_error
10
+ from .base import ToolBase, tool_cn_name
11
+
12
+
13
+ ALLOWED_DIRECTORY_SEARCH_SCOPES = {"MEMBER", "DEPT"}
14
+
15
+
16
+ class DirectoryTools(ToolBase):
17
+ """组织架构工具(中文名:通讯录与组织目录)。
18
+
19
+ 类型:组织目录工具。
20
+ 主要职责:
21
+ 1. 搜索用户、部门、外部联系人;
22
+ 2. 列举内部用户与部门层级;
23
+ 3. 为审批/字段候选提供目录侧数据来源。
24
+ """
25
+
26
+ def register(self, mcp: FastMCP) -> None:
27
+ """注册当前工具到 MCP 服务。"""
28
+ @mcp.tool()
29
+ def directory_search(
30
+ profile: str = DEFAULT_PROFILE,
31
+ query: str = "",
32
+ scopes: list[str] | None = None,
33
+ page_num: int = 1,
34
+ page_size: int = 20,
35
+ ) -> dict[str, Any]:
36
+ return self.directory_search(
37
+ profile=profile,
38
+ query=query,
39
+ scopes=scopes,
40
+ page_num=page_num,
41
+ page_size=page_size,
42
+ )
43
+
44
+ @mcp.tool()
45
+ def directory_list_internal_users(
46
+ profile: str = DEFAULT_PROFILE,
47
+ keyword: str | None = None,
48
+ department_id: int | None = None,
49
+ role_id: int | None = None,
50
+ page_num: int = 1,
51
+ page_size: int = 20,
52
+ include_disabled: bool = False,
53
+ ) -> dict[str, Any]:
54
+ return self.directory_list_internal_users(
55
+ profile=profile,
56
+ keyword=keyword,
57
+ dept_id=department_id,
58
+ role_id=role_id,
59
+ page_num=page_num,
60
+ page_size=page_size,
61
+ contain_disable=include_disabled,
62
+ )
63
+
64
+ @mcp.tool()
65
+ def directory_list_all_internal_users(
66
+ profile: str = DEFAULT_PROFILE,
67
+ keyword: str | None = None,
68
+ department_id: int | None = None,
69
+ role_id: int | None = None,
70
+ page_size: int = 200,
71
+ include_disabled: bool = False,
72
+ max_pages: int = 100,
73
+ ) -> dict[str, Any]:
74
+ return self.directory_list_all_internal_users(
75
+ profile=profile,
76
+ keyword=keyword,
77
+ dept_id=department_id,
78
+ role_id=role_id,
79
+ page_size=page_size,
80
+ contain_disable=include_disabled,
81
+ max_pages=max_pages,
82
+ )
83
+
84
+ @mcp.tool()
85
+ def directory_list_internal_departments(
86
+ profile: str = DEFAULT_PROFILE,
87
+ keyword: str = "",
88
+ page_num: int = 1,
89
+ page_size: int = 20,
90
+ ) -> dict[str, Any]:
91
+ return self.directory_list_internal_departments(
92
+ profile=profile,
93
+ keyword=keyword,
94
+ page_num=page_num,
95
+ page_size=page_size,
96
+ )
97
+
98
+ @mcp.tool()
99
+ def directory_list_all_departments(
100
+ profile: str = DEFAULT_PROFILE,
101
+ parent_department_id: int | None = None,
102
+ max_depth: int = 20,
103
+ max_items: int = 2000,
104
+ ) -> dict[str, Any]:
105
+ return self.directory_list_all_departments(
106
+ profile=profile,
107
+ parent_dept_id=parent_department_id,
108
+ max_depth=max_depth,
109
+ max_items=max_items,
110
+ )
111
+
112
+ @mcp.tool()
113
+ def directory_list_sub_departments(
114
+ profile: str = DEFAULT_PROFILE,
115
+ parent_department_id: int | None = None,
116
+ ) -> dict[str, Any]:
117
+ return self.directory_list_sub_departments(profile=profile, parent_dept_id=parent_department_id)
118
+
119
+ @mcp.tool()
120
+ def directory_list_external_members(
121
+ profile: str = DEFAULT_PROFILE,
122
+ keyword: str | None = None,
123
+ page_num: int = 1,
124
+ page_size: int = 20,
125
+ simple: bool = False,
126
+ ) -> dict[str, Any]:
127
+ return self.directory_list_external_members(
128
+ profile=profile,
129
+ keyword=keyword,
130
+ page_num=page_num,
131
+ page_size=page_size,
132
+ simple=simple,
133
+ )
134
+
135
+ @tool_cn_name("通讯录搜索")
136
+ def directory_search(
137
+ self,
138
+ *,
139
+ profile: str,
140
+ query: str,
141
+ scopes: list[str] | None,
142
+ page_num: int,
143
+ page_size: int,
144
+ ) -> dict[str, Any]:
145
+ """执行组织目录相关逻辑。"""
146
+ normalized_scopes = scopes or ["MEMBER", "DEPT"]
147
+ invalid_scopes = [scope for scope in normalized_scopes if scope not in ALLOWED_DIRECTORY_SEARCH_SCOPES]
148
+ if invalid_scopes:
149
+ raise_tool_error(QingflowApiError.not_supported(f"directory_search only supports internal scopes {sorted(ALLOWED_DIRECTORY_SEARCH_SCOPES)}; got {invalid_scopes}"))
150
+ if not query:
151
+ raise_tool_error(QingflowApiError.config_error("query is required"))
152
+
153
+ def runner(session_profile, context):
154
+ result = self.backend.request(
155
+ "POST",
156
+ context,
157
+ "/member/search",
158
+ json_body={
159
+ "dimensions": normalized_scopes,
160
+ "searchKey": query,
161
+ "pageNum": page_num,
162
+ "pageSize": page_size,
163
+ },
164
+ )
165
+ return {
166
+ "profile": profile,
167
+ "ws_id": session_profile.selected_ws_id,
168
+ "request_route": self._request_route_payload(context),
169
+ "result": result,
170
+ }
171
+
172
+ raw = self._run(profile, runner)
173
+ items = [item for item in _directory_items(raw.get("result")) if isinstance(item, dict)]
174
+ return self._public_directory_response(
175
+ raw,
176
+ items=items,
177
+ pagination={
178
+ "page": page_num,
179
+ "page_size": page_size,
180
+ "returned_items": len(items),
181
+ "reported_total": _coerce_int(_payload_value(raw.get("result"), "total")),
182
+ "page_amount": _coerce_int(_payload_value(raw.get("result"), "pageAmount")),
183
+ },
184
+ selection={"query": query, "scopes": normalized_scopes},
185
+ )
186
+
187
+ @tool_cn_name("内部成员列表")
188
+ def directory_list_internal_users(
189
+ self,
190
+ *,
191
+ profile: str,
192
+ keyword: str | None,
193
+ dept_id: int | None,
194
+ role_id: int | None,
195
+ page_num: int,
196
+ page_size: int,
197
+ contain_disable: bool,
198
+ ) -> dict[str, Any]:
199
+ """执行组织目录相关逻辑。"""
200
+ def runner(session_profile, context):
201
+ params: dict[str, Any] = {
202
+ "pageNum": page_num,
203
+ "pageSize": page_size,
204
+ "containDisable": contain_disable,
205
+ }
206
+ if keyword:
207
+ params["keyword"] = keyword
208
+ if dept_id is not None:
209
+ params["deptId"] = dept_id
210
+ if role_id is not None:
211
+ params["roleId"] = role_id
212
+ result = self.backend.request("GET", context, "/contact", params=params)
213
+ return {
214
+ "profile": profile,
215
+ "ws_id": session_profile.selected_ws_id,
216
+ "request_route": self._request_route_payload(context),
217
+ "result": result,
218
+ }
219
+
220
+ raw = self._run(profile, runner)
221
+ items = [item for item in _directory_items(raw.get("result")) if isinstance(item, dict)]
222
+ return self._public_directory_response(
223
+ raw,
224
+ items=items,
225
+ pagination={
226
+ "page": page_num,
227
+ "page_size": page_size,
228
+ "returned_items": len(items),
229
+ "reported_total": _coerce_int(_payload_value(raw.get("result"), "total")),
230
+ "page_amount": _coerce_int(_payload_value(raw.get("result"), "pageAmount")),
231
+ },
232
+ selection={"keyword": keyword, "department_id": dept_id, "role_id": role_id, "include_disabled": contain_disable},
233
+ )
234
+
235
+ @tool_cn_name("内部成员全量")
236
+ def directory_list_all_internal_users(
237
+ self,
238
+ *,
239
+ profile: str,
240
+ keyword: str | None,
241
+ dept_id: int | None,
242
+ role_id: int | None,
243
+ page_size: int,
244
+ contain_disable: bool,
245
+ max_pages: int,
246
+ ) -> dict[str, Any]:
247
+ """执行组织目录相关逻辑。"""
248
+ if page_size <= 0:
249
+ raise_tool_error(QingflowApiError.config_error("page_size must be positive"))
250
+ if max_pages <= 0:
251
+ raise_tool_error(QingflowApiError.config_error("max_pages must be positive"))
252
+
253
+ def runner(session_profile, context):
254
+ current_page = 1
255
+ fetched_pages = 0
256
+ has_more = False
257
+ reported_total: int | None = None
258
+ seen_keys: set[str] = set()
259
+ items: list[dict[str, Any]] = []
260
+ while fetched_pages < max_pages:
261
+ params: dict[str, Any] = {
262
+ "pageNum": current_page,
263
+ "pageSize": page_size,
264
+ "containDisable": contain_disable,
265
+ }
266
+ if keyword:
267
+ params["keyword"] = keyword
268
+ if dept_id is not None:
269
+ params["deptId"] = dept_id
270
+ if role_id is not None:
271
+ params["roleId"] = role_id
272
+ result = self.backend.request("GET", context, "/contact", params=params)
273
+ page_items = _directory_items(result)
274
+ if reported_total is None:
275
+ reported_total = _coerce_int(_payload_value(result, "total"))
276
+ for item in page_items:
277
+ if not isinstance(item, dict):
278
+ continue
279
+ member_key = _directory_member_key(item)
280
+ if member_key in seen_keys:
281
+ continue
282
+ seen_keys.add(member_key)
283
+ items.append(dict(item))
284
+ fetched_pages += 1
285
+ has_more = _directory_has_more(result, current_page=current_page, page_size=page_size, returned_items=len(page_items))
286
+ if not has_more:
287
+ break
288
+ current_page += 1
289
+ return {
290
+ "profile": profile,
291
+ "ws_id": session_profile.selected_ws_id,
292
+ "request_route": self._request_route_payload(context),
293
+ "items": items,
294
+ "pagination": {
295
+ "page_size": page_size,
296
+ "fetched_pages": fetched_pages,
297
+ "returned_items": len(items),
298
+ "reported_total": reported_total,
299
+ "is_complete": not has_more,
300
+ "has_more": has_more,
301
+ "next_page_num": current_page + 1 if has_more else None,
302
+ "max_pages": max_pages,
303
+ },
304
+ }
305
+
306
+ raw = self._run(profile, runner)
307
+ return self._public_directory_response(
308
+ raw,
309
+ items=[item for item in raw.get("items", []) if isinstance(item, dict)],
310
+ pagination=raw.get("pagination", {}),
311
+ selection={"keyword": keyword, "department_id": dept_id, "role_id": role_id, "include_disabled": contain_disable},
312
+ )
313
+
314
+ @tool_cn_name("内部部门列表")
315
+ def directory_list_internal_departments(
316
+ self,
317
+ *,
318
+ profile: str,
319
+ keyword: str,
320
+ page_num: int,
321
+ page_size: int,
322
+ ) -> dict[str, Any]:
323
+ """执行组织目录相关逻辑。"""
324
+ if page_num <= 0:
325
+ raise_tool_error(QingflowApiError.config_error("page_num must be positive"))
326
+ if page_size <= 0:
327
+ raise_tool_error(QingflowApiError.config_error("page_size must be positive"))
328
+ normalized_keyword = keyword.strip()
329
+
330
+ if not normalized_keyword:
331
+ def runner(session_profile, context):
332
+ fetch_limit = max((page_num + 1) * page_size + 1, page_size + 1)
333
+ items, truncated, deepest_depth = self._walk_department_tree(
334
+ context,
335
+ parent_dept_id=None,
336
+ max_depth=20,
337
+ max_items=fetch_limit,
338
+ )
339
+ start = (page_num - 1) * page_size
340
+ page_items = items[start : start + page_size]
341
+ reported_total = None if truncated else len(items)
342
+ page_amount = None if truncated else ((len(items) + page_size - 1) // page_size if items else 0)
343
+ if truncated and page_items:
344
+ page_amount = max(page_num + 1, (start + len(page_items) + page_size - 1) // page_size)
345
+ return {
346
+ "profile": profile,
347
+ "ws_id": session_profile.selected_ws_id,
348
+ "request_route": self._request_route_payload(context),
349
+ "items": page_items,
350
+ "pagination": {
351
+ "page": page_num,
352
+ "page_size": page_size,
353
+ "returned_items": len(page_items),
354
+ "reported_total": reported_total,
355
+ "page_amount": page_amount,
356
+ "depth_scanned": deepest_depth + 1 if page_items else 0,
357
+ },
358
+ }
359
+
360
+ raw = self._run(profile, runner)
361
+ items = [item for item in raw.get("items", []) if isinstance(item, dict)]
362
+ return self._public_directory_response(
363
+ raw,
364
+ items=items,
365
+ pagination=raw.get("pagination", {}),
366
+ selection={"keyword": None},
367
+ )
368
+
369
+ def runner(session_profile, context):
370
+ result = self.backend.request(
371
+ "GET",
372
+ context,
373
+ "/contact/deptByPage",
374
+ params={"keyword": normalized_keyword, "pageNum": page_num, "pageSize": page_size},
375
+ )
376
+ return {
377
+ "profile": profile,
378
+ "ws_id": session_profile.selected_ws_id,
379
+ "request_route": self._request_route_payload(context),
380
+ "page": result,
381
+ }
382
+
383
+ raw = self._run(profile, runner)
384
+ items = [item for item in _directory_items(raw.get("page")) if isinstance(item, dict)]
385
+ return self._public_directory_response(
386
+ raw,
387
+ items=items,
388
+ pagination={
389
+ "page": page_num,
390
+ "page_size": page_size,
391
+ "returned_items": len(items),
392
+ "reported_total": _coerce_int(_payload_value(raw.get("page"), "total")),
393
+ "page_amount": _coerce_int(_payload_value(raw.get("page"), "pageAmount")),
394
+ },
395
+ selection={"keyword": normalized_keyword},
396
+ )
397
+
398
+ @tool_cn_name("部门树全量")
399
+ def directory_list_all_departments(
400
+ self,
401
+ *,
402
+ profile: str,
403
+ parent_dept_id: int | None,
404
+ max_depth: int,
405
+ max_items: int,
406
+ ) -> dict[str, Any]:
407
+ """执行组织目录相关逻辑。"""
408
+ if max_depth < 0:
409
+ raise_tool_error(QingflowApiError.config_error("max_depth must be non-negative"))
410
+ if max_items <= 0:
411
+ raise_tool_error(QingflowApiError.config_error("max_items must be positive"))
412
+
413
+ def runner(session_profile, context):
414
+ items, truncated, deepest_depth = self._walk_department_tree(
415
+ context,
416
+ parent_dept_id=parent_dept_id,
417
+ max_depth=max_depth,
418
+ max_items=max_items,
419
+ )
420
+ if not items and parent_dept_id is None:
421
+ items, truncated, deepest_depth = self._walk_department_tree(
422
+ context,
423
+ parent_dept_id=0,
424
+ max_depth=max_depth,
425
+ max_items=max_items,
426
+ )
427
+ return {
428
+ "profile": profile,
429
+ "ws_id": session_profile.selected_ws_id,
430
+ "request_route": self._request_route_payload(context),
431
+ "items": items,
432
+ "pagination": {
433
+ "root_parent_department_id": parent_dept_id,
434
+ "returned_items": len(items),
435
+ "fetched_pages": deepest_depth + 1 if items else 0,
436
+ "is_complete": not truncated,
437
+ "has_more": truncated,
438
+ "max_items": max_items,
439
+ },
440
+ }
441
+
442
+ raw = self._run(profile, runner)
443
+ return self._public_directory_response(
444
+ raw,
445
+ items=[item for item in raw.get("items", []) if isinstance(item, dict)],
446
+ pagination=raw.get("pagination", {}),
447
+ selection={"parent_department_id": parent_dept_id, "max_depth": max_depth, "max_items": max_items},
448
+ )
449
+
450
+ @tool_cn_name("子部门列表")
451
+ def directory_list_sub_departments(self, *, profile: str, parent_dept_id: int | None) -> dict[str, Any]:
452
+ """执行组织目录相关逻辑。"""
453
+ def runner(session_profile, context):
454
+ params: dict[str, Any] = {}
455
+ if parent_dept_id is not None:
456
+ params["parentDeptId"] = parent_dept_id
457
+ result = self.backend.request("GET", context, "/contact/subDeptList", params=params)
458
+ return {
459
+ "profile": profile,
460
+ "ws_id": session_profile.selected_ws_id,
461
+ "request_route": self._request_route_payload(context),
462
+ "items": result,
463
+ }
464
+
465
+ raw = self._run(profile, runner)
466
+ items = [item for item in raw.get("items", []) if isinstance(item, dict)]
467
+ return self._public_directory_response(
468
+ raw,
469
+ items=items,
470
+ pagination={"returned_items": len(items)},
471
+ selection={"parent_department_id": parent_dept_id},
472
+ )
473
+
474
+ @tool_cn_name("外部联系人列表")
475
+ def directory_list_external_members(
476
+ self,
477
+ *,
478
+ profile: str,
479
+ keyword: str | None,
480
+ page_num: int,
481
+ page_size: int,
482
+ simple: bool,
483
+ ) -> dict[str, Any]:
484
+ """执行组织目录相关逻辑。"""
485
+ def runner(session_profile, context):
486
+ if simple:
487
+ result = self.backend.request(
488
+ "POST",
489
+ context,
490
+ "/external/member/simple/pageList",
491
+ json_body={"keyword": keyword, "pageNum": page_num, "pageSize": page_size},
492
+ )
493
+ else:
494
+ params: dict[str, Any] = {"pageNum": page_num, "pageSize": page_size}
495
+ if keyword:
496
+ params["keyword"] = keyword
497
+ result = self.backend.request("GET", context, "/external/member/pageList", params=params)
498
+ return {
499
+ "profile": profile,
500
+ "ws_id": session_profile.selected_ws_id,
501
+ "request_route": self._request_route_payload(context),
502
+ "page": result,
503
+ "simple": simple,
504
+ }
505
+
506
+ raw = self._run(profile, runner)
507
+ items = [item for item in _directory_items(raw.get("page")) if isinstance(item, dict)]
508
+ return self._public_directory_response(
509
+ raw,
510
+ items=items,
511
+ pagination={
512
+ "page": page_num,
513
+ "page_size": page_size,
514
+ "returned_items": len(items),
515
+ "reported_total": _coerce_int(_payload_value(raw.get("page"), "total")),
516
+ "page_amount": _coerce_int(_payload_value(raw.get("page"), "pageAmount")),
517
+ },
518
+ selection={"keyword": keyword, "simple": simple},
519
+ )
520
+
521
+ def _request_route_payload(self, context) -> dict[str, Any]: # type: ignore[no-untyped-def]
522
+ """执行内部辅助逻辑。"""
523
+ describe_route = getattr(self.backend, "describe_route", None)
524
+ if callable(describe_route):
525
+ payload = describe_route(context)
526
+ if isinstance(payload, dict):
527
+ return payload
528
+ return {
529
+ "base_url": getattr(context, "base_url", None),
530
+ "qf_version": getattr(context, "qf_version", None),
531
+ "qf_version_source": getattr(context, "qf_version_source", None) or ("context" if getattr(context, "qf_version", None) else "unknown"),
532
+ }
533
+
534
+ def _public_directory_response(
535
+ self,
536
+ raw: dict[str, Any],
537
+ *,
538
+ items: list[dict[str, Any]],
539
+ pagination: dict[str, Any],
540
+ selection: dict[str, Any],
541
+ ) -> dict[str, Any]:
542
+ """执行内部辅助逻辑。"""
543
+ response = dict(raw)
544
+ response["ok"] = bool(raw.get("ok", True))
545
+ response["warnings"] = []
546
+ response["output_profile"] = "normal"
547
+ response["data"] = {
548
+ "items": items,
549
+ "pagination": pagination,
550
+ "selection": selection,
551
+ }
552
+ return response
553
+
554
+ def _walk_department_tree(
555
+ self,
556
+ context, # type: ignore[no-untyped-def]
557
+ *,
558
+ parent_dept_id: int | None,
559
+ max_depth: int,
560
+ max_items: int,
561
+ ) -> tuple[list[dict[str, Any]], bool, int]:
562
+ """执行内部辅助逻辑。"""
563
+ queue: deque[tuple[int | None, int]] = deque([(parent_dept_id, 0)])
564
+ seen_ids: set[int] = set()
565
+ requested_parents: set[int | None] = set()
566
+ items: list[dict[str, Any]] = []
567
+ truncated = False
568
+ deepest_depth = 0
569
+ while queue:
570
+ current_parent, depth = queue.popleft()
571
+ if current_parent in requested_parents:
572
+ continue
573
+ requested_parents.add(current_parent)
574
+ params: dict[str, Any] = {}
575
+ if current_parent is not None:
576
+ params["parentDeptId"] = current_parent
577
+ result = self.backend.request("GET", context, "/contact/subDeptList", params=params)
578
+ page_items = _directory_items(result)
579
+ for item in page_items:
580
+ if not isinstance(item, dict):
581
+ continue
582
+ dept_id = _department_id(item)
583
+ if dept_id is None or dept_id in seen_ids:
584
+ continue
585
+ seen_ids.add(dept_id)
586
+ entry = dict(item)
587
+ if current_parent is not None and "parentDeptId" not in entry:
588
+ entry["parentDeptId"] = current_parent
589
+ entry.setdefault("depth", depth)
590
+ items.append(entry)
591
+ deepest_depth = max(deepest_depth, depth)
592
+ if len(items) >= max_items:
593
+ truncated = True
594
+ return items, truncated, deepest_depth
595
+ if depth < max_depth:
596
+ queue.append((dept_id, depth + 1))
597
+ else:
598
+ truncated = True
599
+ return items, truncated, deepest_depth
600
+
601
+
602
+ def _payload_value(payload: Any, key: str) -> Any:
603
+ if isinstance(payload, dict):
604
+ if key in payload:
605
+ return payload.get(key)
606
+ for container_key in ("data", "result", "page"):
607
+ nested = payload.get(container_key)
608
+ if isinstance(nested, dict) and key in nested:
609
+ return nested.get(key)
610
+ return None
611
+
612
+
613
+ def _directory_items(payload: Any) -> list[Any]:
614
+ if isinstance(payload, list):
615
+ return payload
616
+ if not isinstance(payload, dict):
617
+ return []
618
+ for key in ("list", "items", "rows"):
619
+ value = payload.get(key)
620
+ if isinstance(value, list):
621
+ return value
622
+ for key in ("data", "result", "page"):
623
+ nested = payload.get(key)
624
+ if isinstance(nested, list):
625
+ return nested
626
+ if isinstance(nested, dict):
627
+ for nested_key in ("list", "items", "rows", "result"):
628
+ value = nested.get(nested_key)
629
+ if isinstance(value, list):
630
+ return value
631
+ return []
632
+
633
+
634
+ def _directory_has_more(payload: Any, *, current_page: int, page_size: int, returned_items: int) -> bool:
635
+ page_amount = _coerce_int(_payload_value(payload, "pageAmount"))
636
+ if page_amount is not None:
637
+ return current_page < page_amount
638
+ total = _coerce_int(_payload_value(payload, "total"))
639
+ if total is not None:
640
+ return current_page * page_size < total
641
+ return returned_items >= page_size and returned_items > 0
642
+
643
+
644
+ def _directory_member_key(item: dict[str, Any]) -> str:
645
+ for key in ("uid", "id", "userId"):
646
+ value = item.get(key)
647
+ if value is not None:
648
+ return f"{key}:{value}"
649
+ for key in ("email", "nickName", "name"):
650
+ value = item.get(key)
651
+ if isinstance(value, str) and value:
652
+ return f"{key}:{value}"
653
+ return repr(sorted(item.items()))
654
+
655
+
656
+ def _department_id(item: dict[str, Any]) -> int | None:
657
+ return _coerce_int(item.get("deptId", item.get("id")))
658
+
659
+
660
+ def _coerce_int(value: Any) -> int | None:
661
+ if isinstance(value, bool) or value is None:
662
+ return None
663
+ if isinstance(value, int):
664
+ return value
665
+ if isinstance(value, float):
666
+ return int(value)
667
+ if isinstance(value, str):
668
+ text = value.strip()
669
+ if not text:
670
+ return None
671
+ try:
672
+ return int(text)
673
+ except ValueError:
674
+ return None
675
+ return None