@researai/deepscientist 1.5.9 → 1.5.12

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 (165) hide show
  1. package/README.md +112 -99
  2. package/assets/branding/connector-qq.png +0 -0
  3. package/assets/branding/connector-rokid.png +0 -0
  4. package/assets/branding/connector-weixin.png +0 -0
  5. package/assets/branding/projects.png +0 -0
  6. package/bin/ds.js +519 -63
  7. package/docs/assets/branding/projects.png +0 -0
  8. package/docs/en/00_QUICK_START.md +338 -68
  9. package/docs/en/01_SETTINGS_REFERENCE.md +14 -0
  10. package/docs/en/02_START_RESEARCH_GUIDE.md +180 -4
  11. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
  12. package/docs/en/09_DOCTOR.md +66 -5
  13. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
  14. package/docs/en/11_LICENSE_AND_RISK.md +256 -0
  15. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +446 -0
  16. package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
  17. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  18. package/docs/en/15_CODEX_PROVIDER_SETUP.md +284 -0
  19. package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
  20. package/docs/en/README.md +83 -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 +345 -72
  28. package/docs/zh/01_SETTINGS_REFERENCE.md +14 -0
  29. package/docs/zh/02_START_RESEARCH_GUIDE.md +181 -3
  30. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
  31. package/docs/zh/09_DOCTOR.md +68 -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 +442 -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/15_CODEX_PROVIDER_SETUP.md +285 -0
  38. package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
  39. package/docs/zh/README.md +129 -0
  40. package/install.sh +0 -34
  41. package/package.json +2 -2
  42. package/pyproject.toml +1 -1
  43. package/src/deepscientist/__init__.py +1 -1
  44. package/src/deepscientist/annotations.py +343 -0
  45. package/src/deepscientist/artifact/arxiv.py +484 -37
  46. package/src/deepscientist/artifact/service.py +574 -108
  47. package/src/deepscientist/arxiv_library.py +275 -0
  48. package/src/deepscientist/bash_exec/monitor.py +7 -5
  49. package/src/deepscientist/bash_exec/service.py +93 -21
  50. package/src/deepscientist/bridges/builtins.py +2 -0
  51. package/src/deepscientist/bridges/connectors.py +447 -0
  52. package/src/deepscientist/channels/__init__.py +2 -0
  53. package/src/deepscientist/channels/builtins.py +3 -1
  54. package/src/deepscientist/channels/local.py +3 -3
  55. package/src/deepscientist/channels/qq.py +8 -8
  56. package/src/deepscientist/channels/qq_gateway.py +1 -1
  57. package/src/deepscientist/channels/relay.py +14 -8
  58. package/src/deepscientist/channels/weixin.py +59 -0
  59. package/src/deepscientist/channels/weixin_ilink.py +388 -0
  60. package/src/deepscientist/config/models.py +23 -2
  61. package/src/deepscientist/config/service.py +539 -67
  62. package/src/deepscientist/connector/__init__.py +4 -0
  63. package/src/deepscientist/connector/connector_profiles.py +481 -0
  64. package/src/deepscientist/connector/lingzhu_support.py +668 -0
  65. package/src/deepscientist/connector/qq_profiles.py +206 -0
  66. package/src/deepscientist/connector/weixin_support.py +663 -0
  67. package/src/deepscientist/connector_profiles.py +1 -374
  68. package/src/deepscientist/connector_runtime.py +2 -0
  69. package/src/deepscientist/daemon/api/handlers.py +165 -5
  70. package/src/deepscientist/daemon/api/router.py +13 -1
  71. package/src/deepscientist/daemon/app.py +1444 -67
  72. package/src/deepscientist/doctor.py +4 -5
  73. package/src/deepscientist/gitops/diff.py +120 -29
  74. package/src/deepscientist/lingzhu_support.py +1 -182
  75. package/src/deepscientist/mcp/server.py +135 -7
  76. package/src/deepscientist/prompts/builder.py +128 -11
  77. package/src/deepscientist/qq_profiles.py +1 -196
  78. package/src/deepscientist/quest/node_traces.py +23 -0
  79. package/src/deepscientist/quest/service.py +359 -74
  80. package/src/deepscientist/quest/stage_views.py +71 -5
  81. package/src/deepscientist/runners/codex.py +170 -19
  82. package/src/deepscientist/runners/runtime_overrides.py +6 -0
  83. package/src/deepscientist/shared.py +33 -14
  84. package/src/deepscientist/weixin_support.py +1 -0
  85. package/src/prompts/connectors/lingzhu.md +3 -1
  86. package/src/prompts/connectors/qq.md +2 -1
  87. package/src/prompts/connectors/weixin.md +231 -0
  88. package/src/prompts/contracts/shared_interaction.md +4 -1
  89. package/src/prompts/system.md +61 -9
  90. package/src/skills/analysis-campaign/SKILL.md +46 -6
  91. package/src/skills/analysis-campaign/references/campaign-plan-template.md +21 -8
  92. package/src/skills/baseline/SKILL.md +1 -1
  93. package/src/skills/decision/SKILL.md +1 -1
  94. package/src/skills/experiment/SKILL.md +1 -1
  95. package/src/skills/finalize/SKILL.md +1 -1
  96. package/src/skills/idea/SKILL.md +1 -1
  97. package/src/skills/intake-audit/SKILL.md +1 -1
  98. package/src/skills/rebuttal/SKILL.md +74 -1
  99. package/src/skills/rebuttal/references/response-letter-template.md +55 -11
  100. package/src/skills/review/SKILL.md +118 -1
  101. package/src/skills/review/references/experiment-todo-template.md +23 -0
  102. package/src/skills/review/references/review-report-template.md +16 -0
  103. package/src/skills/review/references/revision-log-template.md +4 -0
  104. package/src/skills/scout/SKILL.md +1 -1
  105. package/src/skills/write/SKILL.md +168 -7
  106. package/src/skills/write/references/paper-experiment-matrix-template.md +131 -0
  107. package/src/tui/package.json +1 -1
  108. package/src/ui/dist/assets/{AiManusChatView-BKZ103sn.js → AiManusChatView-CnJcXynW.js} +156 -48
  109. package/src/ui/dist/assets/{AnalysisPlugin-mTTzGAlK.js → AnalysisPlugin-DeyzPEhV.js} +1 -1
  110. package/src/ui/dist/assets/{CliPlugin-BH58n3GY.js → CliPlugin-CB1YODQn.js} +164 -9
  111. package/src/ui/dist/assets/{CodeEditorPlugin-BKGRUH7e.js → CodeEditorPlugin-B-xicq1e.js} +8 -8
  112. package/src/ui/dist/assets/{CodeViewerPlugin-BMADwFWJ.js → CodeViewerPlugin-DT54ysXa.js} +5 -5
  113. package/src/ui/dist/assets/{DocViewerPlugin-ZOnTIHLN.js → DocViewerPlugin-DQtKT-VD.js} +3 -3
  114. package/src/ui/dist/assets/{GitDiffViewerPlugin-CQ7h1Djm.js → GitDiffViewerPlugin-hqHbCfnv.js} +20 -21
  115. package/src/ui/dist/assets/{ImageViewerPlugin-GVS5MsnC.js → ImageViewerPlugin-OcVo33jV.js} +5 -5
  116. package/src/ui/dist/assets/{LabCopilotPanel-BZNv1JML.js → LabCopilotPanel-DdGwhEUV.js} +11 -11
  117. package/src/ui/dist/assets/{LabPlugin-TWcJsdQA.js → LabPlugin-Ciz1gDaX.js} +2 -1
  118. package/src/ui/dist/assets/{LatexPlugin-DIjHiR2x.js → LatexPlugin-BhmjNQRC.js} +37 -11
  119. package/src/ui/dist/assets/{MarkdownViewerPlugin-D3ooGAH0.js → MarkdownViewerPlugin-BzdVH9Bx.js} +4 -4
  120. package/src/ui/dist/assets/{MarketplacePlugin-DfVfE9hN.js → MarketplacePlugin-DmyHspXt.js} +3 -3
  121. package/src/ui/dist/assets/{NotebookEditor-DDl0_Mc0.js → NotebookEditor-BMXKrDRk.js} +1 -1
  122. package/src/ui/dist/assets/{NotebookEditor-s8JhzuX1.js → NotebookEditor-BTVYRGkm.js} +12 -12
  123. package/src/ui/dist/assets/{PdfLoader-C2Sf6SJM.js → PdfLoader-CvcjJHXv.js} +14 -7
  124. package/src/ui/dist/assets/{PdfMarkdownPlugin-CXFLoIsa.js → PdfMarkdownPlugin-DW2ej8Vk.js} +73 -6
  125. package/src/ui/dist/assets/{PdfViewerPlugin-BYTmz2fK.js → PdfViewerPlugin-CmlDxbhU.js} +103 -34
  126. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
  127. package/src/ui/dist/assets/{SearchPlugin-CjWBI1O9.js → SearchPlugin-DAjQZPSv.js} +1 -1
  128. package/src/ui/dist/assets/{TextViewerPlugin-DdOBU3-S.js → TextViewerPlugin-C-nVAZb_.js} +5 -4
  129. package/src/ui/dist/assets/{VNCViewer-B8HGgLwQ.js → VNCViewer-D7-dIYon.js} +10 -10
  130. package/src/ui/dist/assets/bot-C_G4WtNI.js +21 -0
  131. package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
  132. package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
  133. package/src/ui/dist/assets/{code-BWAY76JP.js → code-Cd7WfiWq.js} +1 -1
  134. package/src/ui/dist/assets/{file-content-C1NwU5oQ.js → file-content-B57zsL9y.js} +1 -1
  135. package/src/ui/dist/assets/{file-diff-panel-CywslwB9.js → file-diff-panel-DVoheLFq.js} +1 -1
  136. package/src/ui/dist/assets/{file-socket-B4kzuOBQ.js → file-socket-B5kXFxZP.js} +1 -1
  137. package/src/ui/dist/assets/{image-D-NZM-6P.js → image-LLOjkMHF.js} +1 -1
  138. package/src/ui/dist/assets/{index-DGIYDuTv.css → index-BQG-1s2o.css} +40 -13
  139. package/src/ui/dist/assets/{index-DHZJ_0TI.js → index-C3r2iGrp.js} +12 -12
  140. package/src/ui/dist/assets/{index-7Chr1g9c.js → index-CLQauncb.js} +15050 -9561
  141. package/src/ui/dist/assets/index-Dxa2eYMY.js +25 -0
  142. package/src/ui/dist/assets/{index-BdM1Gqfr.js → index-hOUOWbW2.js} +2 -2
  143. package/src/ui/dist/assets/{monaco-Cb2uKKe6.js → monaco-BGGAEii3.js} +1 -1
  144. package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DlEr1_y5.js} +16 -1
  145. package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  146. package/src/ui/dist/assets/{popover-Bg72DGgT.js → popover-CWJbJuYY.js} +1 -1
  147. package/src/ui/dist/assets/{project-sync-Ce_0BglY.js → project-sync-CRJiucYO.js} +18 -77
  148. package/src/ui/dist/assets/select-CoHB7pvH.js +1690 -0
  149. package/src/ui/dist/assets/{sigma-DPaACDrh.js → sigma-D5aJWR8J.js} +1 -1
  150. package/src/ui/dist/assets/{index-CDxNdQdz.js → square-check-big-DUK_mnkS.js} +2 -13
  151. package/src/ui/dist/assets/{trash-BvTgE5__.js → trash-ChU3SEE3.js} +1 -1
  152. package/src/ui/dist/assets/{useCliAccess-CgPeMOwP.js → useCliAccess-BrJBV3tY.js} +1 -1
  153. package/src/ui/dist/assets/{useFileDiffOverlay-xPhz7P5B.js → useFileDiffOverlay-C2OQaVWc.js} +1 -1
  154. package/src/ui/dist/assets/{wrap-text-C3Un3YQr.js → wrap-text-C7Qqh-om.js} +1 -1
  155. package/src/ui/dist/assets/{zoom-out-BgxLa0Ri.js → zoom-out-rtX0FKya.js} +1 -1
  156. package/src/ui/dist/index.html +2 -2
  157. package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
  158. package/src/ui/dist/assets/AutoFigurePlugin-C_wWw4AP.js +0 -8149
  159. package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
  160. package/src/ui/dist/assets/Stepper-B0Dd8CxK.js +0 -158
  161. package/src/ui/dist/assets/bibtex-CKaefIN2.js +0 -189
  162. package/src/ui/dist/assets/file-utils-H2fjA46S.js +0 -109
  163. package/src/ui/dist/assets/message-square-BzjLiXir.js +0 -16
  164. package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
  165. package/src/ui/dist/assets/tooltip-C_mA6R0w.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, {})),
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import Any
5
5
 
6
- from ..shared import append_jsonl, ensure_dir, read_jsonl, utc_now
6
+ from ..shared import append_jsonl, count_jsonl, ensure_dir, read_jsonl, utc_now
7
7
  from .base import BaseChannel
8
8
 
9
9
 
@@ -27,6 +27,6 @@ class LocalChannel(BaseChannel):
27
27
  return {
28
28
  "name": self.name,
29
29
  "display_mode": self.display_mode,
30
- "inbox_count": len(read_jsonl(self.root / "inbox.jsonl")),
31
- "outbox_count": len(read_jsonl(self.root / "outbox.jsonl")),
30
+ "inbox_count": count_jsonl(self.root / "inbox.jsonl"),
31
+ "outbox_count": count_jsonl(self.root / "outbox.jsonl"),
32
32
  }
@@ -5,8 +5,8 @@ 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
9
- from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, utc_now, write_json
8
+ from ..connector.qq_profiles import find_qq_profile, list_qq_profiles, merge_qq_profile_config, qq_profile_label
9
+ from ..shared import append_jsonl, count_jsonl, ensure_dir, generate_id, read_json, read_jsonl, read_jsonl_tail, utc_now, write_json
10
10
  from .base import BaseChannel
11
11
 
12
12
 
@@ -387,9 +387,9 @@ class QQRelayChannel(BaseChannel):
387
387
  "main_chat_id": main_chat_id,
388
388
  "last_conversation_id": last_conversation_id,
389
389
  "last_error": last_error,
390
- "inbox_count": len(read_jsonl(self.inbox_path)),
391
- "outbox_count": len(read_jsonl(self.outbox_path)),
392
- "ignored_count": len(read_jsonl(self.ignored_path)),
390
+ "inbox_count": count_jsonl(self.inbox_path),
391
+ "outbox_count": count_jsonl(self.outbox_path),
392
+ "ignored_count": count_jsonl(self.ignored_path),
393
393
  "binding_count": len(bindings),
394
394
  "bindings": bindings,
395
395
  "known_targets": known_targets,
@@ -947,15 +947,15 @@ class QQRelayChannel(BaseChannel):
947
947
 
948
948
  def _recent_events(self) -> list[dict[str, Any]]:
949
949
  events: list[dict[str, Any]] = []
950
- for record in read_jsonl(self.inbox_path)[-self.recent_event_limit :]:
950
+ for record in read_jsonl_tail(self.inbox_path, self.recent_event_limit):
951
951
  event = self._build_recent_event("inbound", record)
952
952
  if event is not None:
953
953
  events.append(event)
954
- for record in read_jsonl(self.outbox_path)[-self.recent_event_limit :]:
954
+ for record in read_jsonl_tail(self.outbox_path, self.recent_event_limit):
955
955
  event = self._build_recent_event("outbound", record)
956
956
  if event is not None:
957
957
  events.append(event)
958
- for record in read_jsonl(self.ignored_path)[-self.recent_event_limit :]:
958
+ for record in read_jsonl_tail(self.ignored_path, self.recent_event_limit):
959
959
  event = self._build_recent_event("ignored", record)
960
960
  if event is not None:
961
961
  events.append(event)
@@ -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,
@@ -21,7 +21,7 @@ from ..connector_profiles import (
21
21
  merge_connector_profile_config,
22
22
  )
23
23
  from ..bridges import get_connector_bridge
24
- from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, utc_now, write_json
24
+ from ..shared import append_jsonl, count_jsonl, ensure_dir, generate_id, read_json, read_jsonl, read_jsonl_tail, utc_now, write_json
25
25
  from .base import BaseChannel
26
26
 
27
27
 
@@ -412,9 +412,9 @@ class GenericRelayChannel(BaseChannel):
412
412
  ),
413
413
  str(runtime_state.get("last_error") or "").strip() or None if isinstance(runtime_state, dict) else None,
414
414
  ),
415
- "inbox_count": len(read_jsonl(self.inbox_path)),
416
- "outbox_count": len(read_jsonl(self.outbox_path)),
417
- "ignored_count": len(read_jsonl(self.ignored_path)),
415
+ "inbox_count": count_jsonl(self.inbox_path),
416
+ "outbox_count": count_jsonl(self.outbox_path),
417
+ "ignored_count": count_jsonl(self.ignored_path),
418
418
  "binding_count": len(bindings),
419
419
  "bindings": bindings,
420
420
  "known_targets": known_targets,
@@ -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"),
@@ -893,15 +894,15 @@ class GenericRelayChannel(BaseChannel):
893
894
 
894
895
  def _recent_events(self) -> list[dict[str, Any]]:
895
896
  events: list[dict[str, Any]] = []
896
- for record in read_jsonl(self.inbox_path)[-self.recent_event_limit :]:
897
+ for record in read_jsonl_tail(self.inbox_path, self.recent_event_limit):
897
898
  event = self._build_recent_event("inbound", record)
898
899
  if event is not None:
899
900
  events.append(event)
900
- for record in read_jsonl(self.outbox_path)[-self.recent_event_limit :]:
901
+ for record in read_jsonl_tail(self.outbox_path, self.recent_event_limit):
901
902
  event = self._build_recent_event("outbound", record)
902
903
  if event is not None:
903
904
  events.append(event)
904
- for record in read_jsonl(self.ignored_path)[-self.recent_event_limit :]:
905
+ for record in read_jsonl_tail(self.ignored_path, self.recent_event_limit):
905
906
  event = self._build_recent_event("ignored", record)
906
907
  if event is not None:
907
908
  events.append(event)
@@ -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: