@researai/deepscientist 1.5.8 → 1.5.11

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 (148) hide show
  1. package/LICENSE +186 -21
  2. package/README.md +108 -95
  3. package/assets/branding/connector-qq.png +0 -0
  4. package/assets/branding/connector-rokid.png +0 -0
  5. package/assets/branding/connector-weixin.png +0 -0
  6. package/assets/branding/projects.png +0 -0
  7. package/bin/ds.js +172 -13
  8. package/docs/assets/branding/projects.png +0 -0
  9. package/docs/en/00_QUICK_START.md +308 -70
  10. package/docs/en/01_SETTINGS_REFERENCE.md +3 -0
  11. package/docs/en/02_START_RESEARCH_GUIDE.md +112 -0
  12. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
  13. package/docs/en/09_DOCTOR.md +41 -5
  14. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
  15. package/docs/en/11_LICENSE_AND_RISK.md +256 -0
  16. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +427 -0
  17. package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
  18. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  19. package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
  20. package/docs/en/README.md +79 -0
  21. package/docs/images/lingzhu/rokid-agent-platform-create.png +0 -0
  22. package/docs/images/weixin/weixin-plugin-entry.png +0 -0
  23. package/docs/images/weixin/weixin-plugin-entry.svg +33 -0
  24. package/docs/images/weixin/weixin-qr-confirm.svg +30 -0
  25. package/docs/images/weixin/weixin-quest-media-flow.svg +44 -0
  26. package/docs/images/weixin/weixin-settings-bind.svg +57 -0
  27. package/docs/zh/00_QUICK_START.md +315 -74
  28. package/docs/zh/01_SETTINGS_REFERENCE.md +3 -0
  29. package/docs/zh/02_START_RESEARCH_GUIDE.md +112 -0
  30. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
  31. package/docs/zh/09_DOCTOR.md +41 -5
  32. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +144 -0
  33. package/docs/zh/11_LICENSE_AND_RISK.md +256 -0
  34. package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +423 -0
  35. package/docs/zh/13_CORE_ARCHITECTURE_GUIDE.md +296 -0
  36. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  37. package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
  38. package/docs/zh/README.md +126 -0
  39. package/install.sh +0 -34
  40. package/package.json +3 -3
  41. package/pyproject.toml +2 -2
  42. package/src/deepscientist/__init__.py +1 -1
  43. package/src/deepscientist/annotations.py +343 -0
  44. package/src/deepscientist/artifact/arxiv.py +484 -37
  45. package/src/deepscientist/artifact/metrics.py +1 -3
  46. package/src/deepscientist/artifact/service.py +1347 -111
  47. package/src/deepscientist/arxiv_library.py +275 -0
  48. package/src/deepscientist/bash_exec/service.py +9 -0
  49. package/src/deepscientist/bridges/builtins.py +2 -0
  50. package/src/deepscientist/bridges/connectors.py +447 -0
  51. package/src/deepscientist/channels/__init__.py +2 -0
  52. package/src/deepscientist/channels/builtins.py +3 -1
  53. package/src/deepscientist/channels/qq.py +1 -1
  54. package/src/deepscientist/channels/qq_gateway.py +1 -1
  55. package/src/deepscientist/channels/relay.py +7 -1
  56. package/src/deepscientist/channels/weixin.py +59 -0
  57. package/src/deepscientist/channels/weixin_ilink.py +317 -0
  58. package/src/deepscientist/config/models.py +22 -2
  59. package/src/deepscientist/config/service.py +431 -60
  60. package/src/deepscientist/connector/__init__.py +4 -0
  61. package/src/deepscientist/connector/connector_profiles.py +481 -0
  62. package/src/deepscientist/connector/lingzhu_support.py +668 -0
  63. package/src/deepscientist/connector/qq_profiles.py +206 -0
  64. package/src/deepscientist/connector/weixin_support.py +663 -0
  65. package/src/deepscientist/connector_profiles.py +1 -374
  66. package/src/deepscientist/connector_runtime.py +2 -0
  67. package/src/deepscientist/daemon/api/handlers.py +295 -5
  68. package/src/deepscientist/daemon/api/router.py +16 -1
  69. package/src/deepscientist/daemon/app.py +1130 -61
  70. package/src/deepscientist/doctor.py +5 -2
  71. package/src/deepscientist/gitops/diff.py +120 -29
  72. package/src/deepscientist/lingzhu_support.py +1 -182
  73. package/src/deepscientist/mcp/server.py +14 -5
  74. package/src/deepscientist/prompts/builder.py +29 -1
  75. package/src/deepscientist/qq_profiles.py +1 -196
  76. package/src/deepscientist/quest/node_traces.py +152 -2
  77. package/src/deepscientist/quest/service.py +169 -43
  78. package/src/deepscientist/quest/stage_views.py +172 -9
  79. package/src/deepscientist/registries/baseline.py +56 -4
  80. package/src/deepscientist/runners/codex.py +55 -3
  81. package/src/deepscientist/weixin_support.py +1 -0
  82. package/src/prompts/connectors/lingzhu.md +3 -1
  83. package/src/prompts/connectors/weixin.md +230 -0
  84. package/src/prompts/system.md +9 -0
  85. package/src/skills/idea/SKILL.md +16 -0
  86. package/src/skills/idea/references/literature-survey-template.md +24 -0
  87. package/src/skills/idea/references/related-work-playbook.md +4 -0
  88. package/src/skills/idea/references/selection-gate.md +9 -0
  89. package/src/skills/write/SKILL.md +1 -1
  90. package/src/tui/package.json +1 -1
  91. package/src/ui/dist/assets/{AiManusChatView-m2FNtwbn.js → AiManusChatView-D0mTXG4-.js} +156 -48
  92. package/src/ui/dist/assets/{AnalysisPlugin-BMTF8EGL.js → AnalysisPlugin-Db0cTXxm.js} +1 -1
  93. package/src/ui/dist/assets/{CliPlugin-BEOWgxCI.js → CliPlugin-DrV8je02.js} +164 -9
  94. package/src/ui/dist/assets/{CodeEditorPlugin-BCXvjqmb.js → CodeEditorPlugin-QXMSCH71.js} +8 -8
  95. package/src/ui/dist/assets/{CodeViewerPlugin-DaJcy3nD.js → CodeViewerPlugin-7hhtWj_E.js} +5 -5
  96. package/src/ui/dist/assets/{DocViewerPlugin-ByfeIq4K.js → DocViewerPlugin-BWMSnRJe.js} +3 -3
  97. package/src/ui/dist/assets/{GitDiffViewerPlugin-Cksf3VZ-.js → GitDiffViewerPlugin-7J9h9Vy_.js} +20 -21
  98. package/src/ui/dist/assets/{ImageViewerPlugin-CFz-OsTS.js → ImageViewerPlugin-CHJl_0lr.js} +5 -5
  99. package/src/ui/dist/assets/{LabCopilotPanel-CJ1cJzoX.js → LabCopilotPanel-1qSow1es.js} +11 -11
  100. package/src/ui/dist/assets/{LabPlugin-BF3dVJwa.js → LabPlugin-eQpPPCEp.js} +2 -1
  101. package/src/ui/dist/assets/{LatexPlugin-DDkwZ6Sj.js → LatexPlugin-BwRfi89Z.js} +7 -7
  102. package/src/ui/dist/assets/{MarkdownViewerPlugin-HAuvurcT.js → MarkdownViewerPlugin-836PVQWV.js} +4 -4
  103. package/src/ui/dist/assets/{MarketplacePlugin-BtoTYy2C.js → MarketplacePlugin-C2y_556i.js} +3 -3
  104. package/src/ui/dist/assets/{NotebookEditor-CSJYx7b-.js → NotebookEditor-BRzJbGsn.js} +12 -12
  105. package/src/ui/dist/assets/{NotebookEditor-DQgRezm_.js → NotebookEditor-DIX7Mlzu.js} +1 -1
  106. package/src/ui/dist/assets/{PdfLoader-DPa_-fv6.js → PdfLoader-DzRaTAlq.js} +14 -7
  107. package/src/ui/dist/assets/{PdfMarkdownPlugin-BZpXOEjm.js → PdfMarkdownPlugin-DZUfIUnp.js} +73 -6
  108. package/src/ui/dist/assets/{PdfViewerPlugin-BT8a6wGR.js → PdfViewerPlugin-BwtICzue.js} +103 -34
  109. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
  110. package/src/ui/dist/assets/{SearchPlugin-D_blveZi.js → SearchPlugin-DHeIAMsx.js} +1 -1
  111. package/src/ui/dist/assets/{TextViewerPlugin-Btx0M3hX.js → TextViewerPlugin-C3tCmFox.js} +5 -4
  112. package/src/ui/dist/assets/{VNCViewer-DImJO4rO.js → VNCViewer-CQsKVm3t.js} +10 -10
  113. package/src/ui/dist/assets/bot-BEA2vWuK.js +21 -0
  114. package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
  115. package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
  116. package/src/ui/dist/assets/{code-BUfXGJSl.js → code-XfbSR8K2.js} +1 -1
  117. package/src/ui/dist/assets/{file-content-VqamwI3X.js → file-content-BjxNaIfy.js} +1 -1
  118. package/src/ui/dist/assets/{file-diff-panel-C_wOoS7a.js → file-diff-panel-D_lLVQk0.js} +1 -1
  119. package/src/ui/dist/assets/{file-socket-D2bTuMVP.js → file-socket-D9x_5vlY.js} +1 -1
  120. package/src/ui/dist/assets/{image-BZkGJ4mM.js → image-BhWT33W1.js} +1 -1
  121. package/src/ui/dist/assets/{index-DdRW6RMJ.js → index--c4iXtuy.js} +12 -12
  122. package/src/ui/dist/assets/{index-CxkvSeKw.js → index-BDxipwrC.js} +2 -2
  123. package/src/ui/dist/assets/{index-DjggJovS.js → index-DZTZ8mWP.js} +14934 -9613
  124. package/src/ui/dist/assets/{index-DXZ1daiJ.css → index-Dqj-Mjb4.css} +2 -13
  125. package/src/ui/dist/assets/index-PJbSbPTy.js +25 -0
  126. package/src/ui/dist/assets/{monaco-DHMc7kKM.js → monaco-K8izTGgo.js} +1 -1
  127. package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DfBors6y.js} +16 -1
  128. package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  129. package/src/ui/dist/assets/{popover-B85oCgCS.js → popover-yFK1J4fL.js} +1 -1
  130. package/src/ui/dist/assets/{project-sync-DOMCcPac.js → project-sync-PENr2zcz.js} +1 -74
  131. package/src/ui/dist/assets/select-CAbJDfYv.js +1690 -0
  132. package/src/ui/dist/assets/{sigma-BO2rQrl3.js → sigma-DEuYJqTl.js} +1 -1
  133. package/src/ui/dist/assets/{index-D9QIGcmc.js → square-check-big-omoSUmcd.js} +2 -13
  134. package/src/ui/dist/assets/{trash-BsVEH_dV.js → trash--F119N47.js} +1 -1
  135. package/src/ui/dist/assets/{useCliAccess-b8L6JuZm.js → useCliAccess-D31UR23I.js} +1 -1
  136. package/src/ui/dist/assets/{useFileDiffOverlay-BY7uA9hV.js → useFileDiffOverlay-BH6KcMzq.js} +1 -1
  137. package/src/ui/dist/assets/{wrap-text-BwyVuUIK.js → wrap-text-CZ613PM5.js} +1 -1
  138. package/src/ui/dist/assets/{zoom-out-RDpLugQP.js → zoom-out-BgDLAv3z.js} +1 -1
  139. package/src/ui/dist/index.html +2 -2
  140. package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
  141. package/src/ui/dist/assets/AutoFigurePlugin-DxPdMUNb.js +0 -8149
  142. package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
  143. package/src/ui/dist/assets/Stepper-DH2k75Vo.js +0 -158
  144. package/src/ui/dist/assets/bibtex-B-Hqu0Sg.js +0 -189
  145. package/src/ui/dist/assets/file-utils--zJCPN1i.js +0 -109
  146. package/src/ui/dist/assets/message-square-FUIPIhU2.js +0 -16
  147. package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
  148. package/src/ui/dist/assets/tooltip-B1OspAkx.js +0 -108
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import base64
4
4
  import json
5
+ import mimetypes
6
+ import os
5
7
  import time
6
8
  from hashlib import sha256
7
9
  from hmac import new as hmac_new
@@ -12,6 +14,17 @@ from urllib.request import Request
12
14
 
13
15
  from ..network import urlopen_with_proxy as urlopen
14
16
  from ..shared import append_jsonl, ensure_dir, utc_now
17
+ from ..connector.weixin_support import (
18
+ WEIXIN_UPLOAD_MEDIA_FILE,
19
+ WEIXIN_UPLOAD_MEDIA_IMAGE,
20
+ WEIXIN_UPLOAD_MEDIA_VIDEO,
21
+ download_weixin_remote_attachment,
22
+ get_weixin_context_token,
23
+ normalize_weixin_base_url,
24
+ normalize_weixin_cdn_base_url,
25
+ send_weixin_message,
26
+ upload_local_media_to_weixin,
27
+ )
15
28
  from .base import BaseConnectorBridge, BridgeWebhookResult
16
29
 
17
30
 
@@ -757,6 +770,440 @@ class QQConnectorBridge(BaseConnectorBridge):
757
770
  return access_token
758
771
 
759
772
 
773
+ class WeixinSendItemsError(RuntimeError):
774
+ def __init__(self, message: str, *, message_ids: list[str], failed_index: int) -> None:
775
+ super().__init__(message)
776
+ self.message_ids = list(message_ids)
777
+ self.failed_index = int(failed_index)
778
+
779
+
780
+ class WeixinConnectorBridge(BaseConnectorBridge):
781
+ name = "weixin"
782
+ _MEDIA_ITEM_TYPES = {2, 4, 5}
783
+ _MEDIA_SEND_INITIAL_DELAY_SECONDS = 0.8
784
+ _MEDIA_SEND_RETRY_DELAYS_SECONDS = (1.5, 3.0)
785
+
786
+ def deliver(self, payload: dict[str, Any], config: dict[str, Any]) -> dict[str, Any] | None:
787
+ return self.deliver_direct(payload, config)
788
+
789
+ def deliver_direct(self, payload: dict[str, Any], config: dict[str, Any]) -> dict[str, Any] | None:
790
+ token = self.read_secret(config, "bot_token", "bot_token_env")
791
+ if not token:
792
+ return None
793
+ target = self.extract_target(payload.get("conversation_id"))
794
+ to_user_id = str(target.get("chat_id") or "").strip()
795
+ if not to_user_id:
796
+ return {
797
+ "ok": False,
798
+ "queued": False,
799
+ "transport": "weixin-ilink",
800
+ "error": "Weixin outbound target is empty.",
801
+ }
802
+ connector_root = self._connector_root(config)
803
+ context_token = get_weixin_context_token(connector_root, to_user_id)
804
+ if not context_token:
805
+ return {
806
+ "ok": False,
807
+ "queued": False,
808
+ "transport": "weixin-ilink",
809
+ "error": f"Weixin context_token is missing for `{to_user_id}`. Wait for one inbound message first.",
810
+ }
811
+
812
+ native_attachments, residual_attachments, warnings = self._partition_native_attachments(payload.get("attachments"))
813
+ rendered_text = self.render_text(payload.get("text"), residual_attachments)
814
+ base_url = normalize_weixin_base_url(config.get("base_url"))
815
+ cdn_base_url = normalize_weixin_cdn_base_url(config.get("cdn_base_url"))
816
+ route_tag = str(config.get("route_tag") or "").strip() or None
817
+ timeout_ms = int(config.get("request_timeout_ms") or 15_000)
818
+ parts: list[dict[str, Any]] = []
819
+ temp_dir = ensure_dir(connector_root / "tmp")
820
+
821
+ try:
822
+ if native_attachments:
823
+ item_list: list[dict[str, Any]] = []
824
+ for index, attachment in enumerate(native_attachments, start=1):
825
+ media_type, message_item, resolved_path = self._prepare_attachment_item(
826
+ attachment=attachment,
827
+ to_user_id=to_user_id,
828
+ base_url=base_url,
829
+ cdn_base_url=cdn_base_url,
830
+ token=token,
831
+ route_tag=route_tag,
832
+ timeout_ms=timeout_ms,
833
+ quest_root=payload.get("quest_root"),
834
+ temp_dir=temp_dir,
835
+ )
836
+ item_list.append(message_item)
837
+ parts.append(
838
+ {
839
+ "part": f"attachment_{index}",
840
+ "ok": True,
841
+ "media_type": media_type,
842
+ "path": str(resolved_path),
843
+ }
844
+ )
845
+ if rendered_text:
846
+ item_list.append(
847
+ {
848
+ "type": 1,
849
+ "text_item": {"text": rendered_text},
850
+ }
851
+ )
852
+ parts.append({"part": "text", "ok": True})
853
+ try:
854
+ send_result = self._send_items(
855
+ to_user_id=to_user_id,
856
+ context_token=context_token,
857
+ item_list=item_list,
858
+ base_url=base_url,
859
+ token=token,
860
+ route_tag=route_tag,
861
+ timeout_ms=timeout_ms,
862
+ )
863
+ message_ids = list(send_result.get("message_ids") or [])
864
+ if not message_ids and send_result.get("message_id"):
865
+ message_ids = [str(send_result.get("message_id"))]
866
+ for index, part in enumerate(parts):
867
+ if index < len(message_ids):
868
+ part["message_id"] = message_ids[index]
869
+ except WeixinSendItemsError as exc:
870
+ for index, part in enumerate(parts):
871
+ if index < len(exc.message_ids):
872
+ part["message_id"] = exc.message_ids[index]
873
+ elif index == exc.failed_index:
874
+ part["ok"] = False
875
+ part["error"] = str(exc)
876
+ elif index > exc.failed_index:
877
+ part["ok"] = False
878
+ part["error"] = "Skipped because an earlier Weixin send failed."
879
+ raise
880
+ elif rendered_text:
881
+ parts.append(
882
+ {
883
+ "part": "text",
884
+ **self._send_text(
885
+ to_user_id=to_user_id,
886
+ context_token=context_token,
887
+ text=rendered_text,
888
+ base_url=base_url,
889
+ token=token,
890
+ route_tag=route_tag,
891
+ timeout_ms=timeout_ms,
892
+ ),
893
+ }
894
+ )
895
+ else:
896
+ warnings.append("Weixin outbound payload contained neither text nor sendable attachments.")
897
+ except Exception as exc:
898
+ return {
899
+ "ok": False,
900
+ "queued": False,
901
+ "transport": "weixin-ilink",
902
+ "parts": parts,
903
+ "warnings": warnings,
904
+ "error": str(exc),
905
+ }
906
+
907
+ succeeded = [item for item in parts if bool(item.get("ok"))]
908
+ failed = [item for item in parts if not bool(item.get("ok"))]
909
+ error_messages = [str(item.get("error") or "").strip() for item in failed if str(item.get("error") or "").strip()]
910
+ error_messages.extend(warnings)
911
+ last_success = succeeded[-1] if succeeded else {}
912
+ return {
913
+ "ok": bool(succeeded),
914
+ "queued": False,
915
+ "partial": bool(succeeded and (failed or warnings)),
916
+ "transport": "weixin-ilink",
917
+ "message_id": last_success.get("message_id"),
918
+ "parts": parts,
919
+ "warnings": warnings,
920
+ "error": "; ".join(error_messages) if error_messages else None,
921
+ }
922
+
923
+ @staticmethod
924
+ def _partition_native_attachments(
925
+ attachments: Any,
926
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
927
+ native_items: list[dict[str, Any]] = []
928
+ residual_items: list[dict[str, Any]] = []
929
+ warnings: list[str] = []
930
+ for index, raw_item in enumerate(attachments if isinstance(attachments, list) else [], start=1):
931
+ if not isinstance(raw_item, dict):
932
+ continue
933
+ item = dict(raw_item)
934
+ if str(item.get("path_error") or "").strip():
935
+ warnings.append(f"attachment {index}: path resolution failed for {item.get('path')}")
936
+ continue
937
+ connector_delivery = item.get("connector_delivery") if isinstance(item.get("connector_delivery"), dict) else {}
938
+ weixin_delivery = connector_delivery.get("weixin") if isinstance(connector_delivery.get("weixin"), dict) else {}
939
+ media_kind = str(weixin_delivery.get("media_kind") or "").strip().lower()
940
+ if media_kind in {"image", "video", "file"}:
941
+ item["weixin_media_kind"] = media_kind
942
+ if WeixinConnectorBridge._has_sendable_attachment_reference(item):
943
+ native_items.append(item)
944
+ continue
945
+ if media_kind in {"image", "video", "file"}:
946
+ warnings.append(
947
+ "attachment "
948
+ f"{index}: Weixin native media delivery was requested but no usable path/url/source_path/output_path/artifact_path was provided."
949
+ )
950
+ residual_items.append(item)
951
+ return native_items, residual_items, warnings
952
+
953
+ @staticmethod
954
+ def _connector_root(config: dict[str, Any]) -> Path:
955
+ raw = str(config.get("_connector_root") or "").strip()
956
+ if raw:
957
+ return ensure_dir(Path(raw))
958
+ return ensure_dir(Path.cwd() / ".deepscientist-weixin")
959
+
960
+ @staticmethod
961
+ def _next_client_id() -> str:
962
+ return f"openclaw-weixin:{int(time.time() * 1000)}-{os.urandom(4).hex()}"
963
+
964
+ @classmethod
965
+ def _item_type(cls, item: dict[str, Any]) -> int:
966
+ return int(item.get("type") or 0)
967
+
968
+ @classmethod
969
+ def _is_media_item(cls, item: dict[str, Any]) -> bool:
970
+ return cls._item_type(item) in cls._MEDIA_ITEM_TYPES
971
+
972
+ @classmethod
973
+ def _retry_delays_for_item(cls, item: dict[str, Any], exc: Exception) -> tuple[float, ...]:
974
+ message = str(exc or "").strip().lower()
975
+ if "ret=-2" in message and cls._item_type(item) in {4, 5}:
976
+ return cls._MEDIA_SEND_RETRY_DELAYS_SECONDS
977
+ return ()
978
+
979
+ def _send_items(
980
+ self,
981
+ *,
982
+ to_user_id: str,
983
+ context_token: str,
984
+ item_list: list[dict[str, Any]],
985
+ base_url: str,
986
+ token: str,
987
+ route_tag: str | None,
988
+ timeout_ms: int,
989
+ ) -> dict[str, Any]:
990
+ if not item_list:
991
+ return {"ok": False, "error": "Weixin outbound item_list is empty."}
992
+ message_ids: list[str] = []
993
+ for item in item_list:
994
+ media_item = self._is_media_item(item)
995
+ if media_item:
996
+ time.sleep(self._MEDIA_SEND_INITIAL_DELAY_SECONDS)
997
+ retry_delays: tuple[float, ...] = ()
998
+ for attempt in range(1 + len(self._MEDIA_SEND_RETRY_DELAYS_SECONDS)):
999
+ client_id = self._next_client_id()
1000
+ try:
1001
+ send_weixin_message(
1002
+ base_url=base_url,
1003
+ token=token,
1004
+ route_tag=route_tag,
1005
+ timeout_ms=timeout_ms,
1006
+ body={
1007
+ "msg": {
1008
+ "from_user_id": "",
1009
+ "to_user_id": to_user_id,
1010
+ "client_id": client_id,
1011
+ "message_type": 2,
1012
+ "message_state": 2,
1013
+ "context_token": context_token,
1014
+ "item_list": [item],
1015
+ }
1016
+ },
1017
+ )
1018
+ message_ids.append(client_id)
1019
+ break
1020
+ except Exception as exc:
1021
+ retry_delays = self._retry_delays_for_item(item, exc)
1022
+ if attempt >= len(retry_delays):
1023
+ raise WeixinSendItemsError(
1024
+ str(exc),
1025
+ message_ids=message_ids,
1026
+ failed_index=len(message_ids),
1027
+ ) from exc
1028
+ time.sleep(retry_delays[attempt])
1029
+ return {
1030
+ "ok": True,
1031
+ "message_id": message_ids[-1],
1032
+ "message_ids": message_ids,
1033
+ }
1034
+
1035
+ def _send_text(
1036
+ self,
1037
+ *,
1038
+ to_user_id: str,
1039
+ context_token: str,
1040
+ text: str,
1041
+ base_url: str,
1042
+ token: str,
1043
+ route_tag: str | None,
1044
+ timeout_ms: int,
1045
+ ) -> dict[str, Any]:
1046
+ if not str(text or "").strip():
1047
+ return {"ok": False, "error": "Weixin text content is empty."}
1048
+ return self._send_items(
1049
+ to_user_id=to_user_id,
1050
+ context_token=context_token,
1051
+ item_list=[
1052
+ {
1053
+ "type": 1,
1054
+ "text_item": {"text": text},
1055
+ }
1056
+ ],
1057
+ base_url=base_url,
1058
+ token=token,
1059
+ route_tag=route_tag,
1060
+ timeout_ms=timeout_ms,
1061
+ )
1062
+
1063
+ def _prepare_attachment_item(
1064
+ self,
1065
+ *,
1066
+ attachment: dict[str, Any],
1067
+ to_user_id: str,
1068
+ base_url: str,
1069
+ cdn_base_url: str,
1070
+ token: str,
1071
+ route_tag: str | None,
1072
+ timeout_ms: int,
1073
+ quest_root: Any,
1074
+ temp_dir: Path,
1075
+ ) -> tuple[str, dict[str, Any], Path]:
1076
+ resolved_path = self._resolve_attachment_path(attachment=attachment, quest_root=quest_root, temp_dir=temp_dir)
1077
+ media_type, message_item = self._build_media_item(
1078
+ attachment=attachment,
1079
+ file_path=resolved_path,
1080
+ to_user_id=to_user_id,
1081
+ base_url=base_url,
1082
+ cdn_base_url=cdn_base_url,
1083
+ token=token,
1084
+ route_tag=route_tag,
1085
+ timeout_ms=timeout_ms,
1086
+ )
1087
+ return media_type, message_item, resolved_path
1088
+
1089
+ def _build_media_item(
1090
+ self,
1091
+ *,
1092
+ attachment: dict[str, Any],
1093
+ file_path: Path,
1094
+ to_user_id: str,
1095
+ base_url: str,
1096
+ cdn_base_url: str,
1097
+ token: str,
1098
+ route_tag: str | None,
1099
+ timeout_ms: int,
1100
+ ) -> tuple[str, dict[str, Any]]:
1101
+ requested_media_kind = str(attachment.get("weixin_media_kind") or "").strip().lower()
1102
+ content_type = str(attachment.get("content_type") or "").strip().lower()
1103
+ mime_type = content_type or str(mimetypes.guess_type(file_path.name)[0] or "application/octet-stream").lower()
1104
+ if requested_media_kind == "image" or (not requested_media_kind and mime_type.startswith("image/")):
1105
+ uploaded = upload_local_media_to_weixin(
1106
+ file_path=file_path,
1107
+ to_user_id=to_user_id,
1108
+ base_url=base_url,
1109
+ cdn_base_url=cdn_base_url,
1110
+ token=token,
1111
+ media_type=WEIXIN_UPLOAD_MEDIA_IMAGE,
1112
+ route_tag=route_tag,
1113
+ timeout_ms=timeout_ms,
1114
+ )
1115
+ return "image", {
1116
+ "type": 2,
1117
+ "image_item": {
1118
+ "media": {
1119
+ "encrypt_query_param": uploaded["download_param"],
1120
+ "aes_key": uploaded["aes_key_base64"],
1121
+ "encrypt_type": 1,
1122
+ },
1123
+ "mid_size": uploaded["ciphertext_size"],
1124
+ },
1125
+ }
1126
+ if requested_media_kind == "video" or (not requested_media_kind and mime_type.startswith("video/")):
1127
+ uploaded = upload_local_media_to_weixin(
1128
+ file_path=file_path,
1129
+ to_user_id=to_user_id,
1130
+ base_url=base_url,
1131
+ cdn_base_url=cdn_base_url,
1132
+ token=token,
1133
+ media_type=WEIXIN_UPLOAD_MEDIA_VIDEO,
1134
+ route_tag=route_tag,
1135
+ timeout_ms=timeout_ms,
1136
+ )
1137
+ return "video", {
1138
+ "type": 5,
1139
+ "video_item": {
1140
+ "media": {
1141
+ "encrypt_query_param": uploaded["download_param"],
1142
+ "aes_key": uploaded["aes_key_base64"],
1143
+ "encrypt_type": 1,
1144
+ },
1145
+ "video_size": uploaded["ciphertext_size"],
1146
+ },
1147
+ }
1148
+ uploaded = upload_local_media_to_weixin(
1149
+ file_path=file_path,
1150
+ to_user_id=to_user_id,
1151
+ base_url=base_url,
1152
+ cdn_base_url=cdn_base_url,
1153
+ token=token,
1154
+ media_type=WEIXIN_UPLOAD_MEDIA_FILE,
1155
+ route_tag=route_tag,
1156
+ timeout_ms=timeout_ms,
1157
+ )
1158
+ return "file", {
1159
+ "type": 4,
1160
+ "file_item": {
1161
+ "media": {
1162
+ "encrypt_query_param": uploaded["download_param"],
1163
+ "aes_key": uploaded["aes_key_base64"],
1164
+ "encrypt_type": 1,
1165
+ },
1166
+ "file_name": file_path.name,
1167
+ "len": str(uploaded["file_size"]),
1168
+ },
1169
+ }
1170
+
1171
+ @staticmethod
1172
+ def _has_sendable_attachment_reference(attachment: dict[str, Any]) -> bool:
1173
+ for key in ("path", "source_path", "output_path", "artifact_path", "url"):
1174
+ if str(attachment.get(key) or "").strip():
1175
+ return True
1176
+ return False
1177
+
1178
+ def _resolve_attachment_path(self, *, attachment: dict[str, Any], quest_root: Any, temp_dir: Path) -> Path:
1179
+ base_root = Path(str(quest_root or "").strip()) if str(quest_root or "").strip() else Path.cwd()
1180
+ last_error: Exception | None = None
1181
+ for key in ("path", "source_path", "output_path", "artifact_path"):
1182
+ raw_path = str(attachment.get(key) or "").strip()
1183
+ if not raw_path:
1184
+ continue
1185
+ candidate = Path(raw_path)
1186
+ if not candidate.is_absolute():
1187
+ candidate = (base_root / candidate).resolve()
1188
+ else:
1189
+ candidate = candidate.resolve()
1190
+ if not candidate.exists():
1191
+ last_error = FileNotFoundError(f"Weixin attachment {key} does not exist: {candidate}")
1192
+ continue
1193
+ if not candidate.is_file():
1194
+ last_error = IsADirectoryError(f"Weixin attachment {key} is not a file: {candidate}")
1195
+ continue
1196
+ return candidate
1197
+ raw_url = str(attachment.get("url") or "").strip()
1198
+ if raw_url:
1199
+ return download_weixin_remote_attachment(url=raw_url, dest_dir=temp_dir)
1200
+ if last_error is not None:
1201
+ raise last_error
1202
+ raise FileNotFoundError(
1203
+ "Weixin attachment requires a usable `path`, `source_path`, `output_path`, `artifact_path`, or `url`."
1204
+ )
1205
+
1206
+
760
1207
  class PassthroughConnectorBridge(BaseConnectorBridge):
761
1208
  def parse_webhook(self, *, method: str, headers: dict[str, str], query: dict[str, list[str]], raw_body: bytes, body: dict[str, Any] | None, config: dict[str, Any]) -> BridgeWebhookResult:
762
1209
  if method != "POST":
@@ -4,12 +4,14 @@ from .local import LocalChannel
4
4
  from .qq import QQRelayChannel
5
5
  from .relay import GenericRelayChannel
6
6
  from .registry import get_channel_factory, list_channel_names, register_channel
7
+ from .weixin import WeixinRelayChannel
7
8
 
8
9
  __all__ = [
9
10
  "BaseChannel",
10
11
  "GenericRelayChannel",
11
12
  "LocalChannel",
12
13
  "QQRelayChannel",
14
+ "WeixinRelayChannel",
13
15
  "get_channel_factory",
14
16
  "list_channel_names",
15
17
  "register_builtin_channels",
@@ -7,12 +7,14 @@ from .local import LocalChannel
7
7
  from .qq import QQRelayChannel
8
8
  from .relay import GenericRelayChannel
9
9
  from .registry import register_channel
10
+ from .weixin import WeixinRelayChannel
10
11
 
11
12
 
12
13
  def register_builtin_channels(*, home: Path, connectors_config: dict[str, Any]) -> None:
13
14
  register_channel("local", lambda **_: LocalChannel(home))
14
15
  register_channel("qq", lambda **_: QQRelayChannel(home, connectors_config.get("qq", {})))
15
- for name in ("telegram", "discord", "slack", "feishu", "whatsapp"):
16
+ register_channel("weixin", lambda **_: WeixinRelayChannel(home, connectors_config.get("weixin", {})))
17
+ for name in ("telegram", "discord", "slack", "feishu", "whatsapp", "lingzhu"):
16
18
  register_channel(
17
19
  name,
18
20
  lambda *, _name=name, **_: GenericRelayChannel(home, _name, connectors_config.get(_name, {})),
@@ -5,7 +5,7 @@ from typing import Any
5
5
 
6
6
  from ..connector_runtime import build_discovered_target, conversation_identity_key, format_conversation_id, merge_discovered_targets, parse_conversation_id
7
7
  from ..bridges import get_connector_bridge
8
- from ..qq_profiles import find_qq_profile, list_qq_profiles, merge_qq_profile_config, qq_profile_label
8
+ from ..connector.qq_profiles import find_qq_profile, list_qq_profiles, merge_qq_profile_config, qq_profile_label
9
9
  from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, utc_now, write_json
10
10
  from .base import BaseChannel
11
11
 
@@ -12,7 +12,7 @@ from websockets.exceptions import ConnectionClosed
12
12
  from ..bridges.connectors import QQConnectorBridge
13
13
  from ..connector_runtime import format_conversation_id
14
14
  from ..network import urlopen_with_proxy as urlopen, websocket_connect_with_proxy as websocket_connect
15
- from ..qq_profiles import qq_profile_label
15
+ from ..connector.qq_profiles import qq_profile_label
16
16
  from ..shared import read_json, utc_now, write_json
17
17
 
18
18
 
@@ -13,7 +13,7 @@ from ..connector_runtime import (
13
13
  merge_discovered_targets,
14
14
  parse_conversation_id,
15
15
  )
16
- from ..connector_profiles import (
16
+ from ..connector.connector_profiles import (
17
17
  PROFILEABLE_CONNECTOR_NAMES,
18
18
  connector_profile_label,
19
19
  find_connector_profile,
@@ -627,6 +627,7 @@ class GenericRelayChannel(BaseChannel):
627
627
  "text": text,
628
628
  "attachments": attachments,
629
629
  "surface_actions": [dict(item) for item in (payload.get("surface_actions") or []) if isinstance(item, dict)],
630
+ "connector_hints": dict(payload.get("connector_hints")) if isinstance(payload.get("connector_hints"), dict) else {},
630
631
  "quest_id": payload.get("quest_id"),
631
632
  "quest_root": payload.get("quest_root"),
632
633
  "importance": payload.get("importance"),
@@ -981,6 +982,11 @@ class GenericRelayChannel(BaseChannel):
981
982
  self._secret("access_token", "access_token_env", config=payload)
982
983
  and str(payload.get("phone_number_id") or "").strip()
983
984
  )
985
+ if self.name == "weixin":
986
+ return bool(
987
+ self._secret("bot_token", "bot_token_env", config=payload)
988
+ and str(payload.get("account_id") or "").strip()
989
+ )
984
990
  return False
985
991
 
986
992
  def _secret(self, key: str, env_key: str, *, config: dict[str, Any] | None = None) -> str:
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from ..connector_runtime import parse_conversation_id
7
+ from ..bridges import get_connector_bridge
8
+ from .relay import GenericRelayChannel
9
+
10
+
11
+ class WeixinRelayChannel(GenericRelayChannel):
12
+ name = "weixin"
13
+
14
+ def __init__(self, home: Path, config: dict[str, Any] | None = None) -> None:
15
+ super().__init__(home, "weixin", config)
16
+
17
+ def normalize_inbound(self, payload: dict[str, Any]) -> dict[str, Any]:
18
+ normalized = super().normalize_inbound(payload)
19
+ attachments = [dict(item) for item in (payload.get("attachments") or []) if isinstance(item, dict)]
20
+ if not normalized.get("accepted", False):
21
+ if "raw_event" in payload and isinstance(payload.get("raw_event"), dict):
22
+ normalized["raw_event"] = dict(payload["raw_event"])
23
+ if str(payload.get("context_token") or "").strip():
24
+ normalized["context_token"] = str(payload.get("context_token") or "").strip()
25
+ if attachments:
26
+ normalized["attachments"] = attachments
27
+ return normalized
28
+ if "raw_event" in payload and isinstance(payload.get("raw_event"), dict):
29
+ normalized["raw_event"] = dict(payload["raw_event"])
30
+ if str(payload.get("context_token") or "").strip():
31
+ normalized["context_token"] = str(payload.get("context_token") or "").strip()
32
+ if attachments:
33
+ normalized["attachments"] = attachments
34
+ return normalized
35
+
36
+ def status(self) -> dict[str, Any]:
37
+ payload = super().status()
38
+ details = dict(payload.get("details") or {})
39
+ details.update(
40
+ {
41
+ "base_url": str(self.config.get("base_url") or "").strip() or None,
42
+ "cdn_base_url": str(self.config.get("cdn_base_url") or "").strip() or None,
43
+ "account_id": str(self.config.get("account_id") or "").strip() or None,
44
+ "login_user_id": str(self.config.get("login_user_id") or "").strip() or None,
45
+ }
46
+ )
47
+ payload["details"] = details
48
+ return payload
49
+
50
+ def _deliver(self, record: dict[str, Any]) -> dict[str, Any] | None:
51
+ delivery_config = dict(self.config)
52
+ parsed = parse_conversation_id(record.get("conversation_id"))
53
+ if parsed is not None:
54
+ delivery_config["conversation_id"] = parsed.get("conversation_id")
55
+ delivery_config["_connector_root"] = str(self.root)
56
+ bridge = get_connector_bridge(self.name)
57
+ if bridge is None:
58
+ return None
59
+ return bridge.deliver(record, delivery_config)