@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.
- package/LICENSE +186 -21
- package/README.md +108 -95
- package/assets/branding/connector-qq.png +0 -0
- package/assets/branding/connector-rokid.png +0 -0
- package/assets/branding/connector-weixin.png +0 -0
- package/assets/branding/projects.png +0 -0
- package/bin/ds.js +172 -13
- package/docs/assets/branding/projects.png +0 -0
- package/docs/en/00_QUICK_START.md +308 -70
- package/docs/en/01_SETTINGS_REFERENCE.md +3 -0
- package/docs/en/02_START_RESEARCH_GUIDE.md +112 -0
- package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
- package/docs/en/09_DOCTOR.md +41 -5
- package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
- package/docs/en/11_LICENSE_AND_RISK.md +256 -0
- package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +427 -0
- package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
- package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
- package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
- package/docs/en/README.md +79 -0
- package/docs/images/lingzhu/rokid-agent-platform-create.png +0 -0
- package/docs/images/weixin/weixin-plugin-entry.png +0 -0
- package/docs/images/weixin/weixin-plugin-entry.svg +33 -0
- package/docs/images/weixin/weixin-qr-confirm.svg +30 -0
- package/docs/images/weixin/weixin-quest-media-flow.svg +44 -0
- package/docs/images/weixin/weixin-settings-bind.svg +57 -0
- package/docs/zh/00_QUICK_START.md +315 -74
- package/docs/zh/01_SETTINGS_REFERENCE.md +3 -0
- package/docs/zh/02_START_RESEARCH_GUIDE.md +112 -0
- package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
- package/docs/zh/09_DOCTOR.md +41 -5
- package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +144 -0
- package/docs/zh/11_LICENSE_AND_RISK.md +256 -0
- package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +423 -0
- package/docs/zh/13_CORE_ARCHITECTURE_GUIDE.md +296 -0
- package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
- package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
- package/docs/zh/README.md +126 -0
- package/install.sh +0 -34
- package/package.json +3 -3
- package/pyproject.toml +2 -2
- package/src/deepscientist/__init__.py +1 -1
- package/src/deepscientist/annotations.py +343 -0
- package/src/deepscientist/artifact/arxiv.py +484 -37
- package/src/deepscientist/artifact/metrics.py +1 -3
- package/src/deepscientist/artifact/service.py +1347 -111
- package/src/deepscientist/arxiv_library.py +275 -0
- package/src/deepscientist/bash_exec/service.py +9 -0
- package/src/deepscientist/bridges/builtins.py +2 -0
- package/src/deepscientist/bridges/connectors.py +447 -0
- package/src/deepscientist/channels/__init__.py +2 -0
- package/src/deepscientist/channels/builtins.py +3 -1
- package/src/deepscientist/channels/qq.py +1 -1
- package/src/deepscientist/channels/qq_gateway.py +1 -1
- package/src/deepscientist/channels/relay.py +7 -1
- package/src/deepscientist/channels/weixin.py +59 -0
- package/src/deepscientist/channels/weixin_ilink.py +317 -0
- package/src/deepscientist/config/models.py +22 -2
- package/src/deepscientist/config/service.py +431 -60
- package/src/deepscientist/connector/__init__.py +4 -0
- package/src/deepscientist/connector/connector_profiles.py +481 -0
- package/src/deepscientist/connector/lingzhu_support.py +668 -0
- package/src/deepscientist/connector/qq_profiles.py +206 -0
- package/src/deepscientist/connector/weixin_support.py +663 -0
- package/src/deepscientist/connector_profiles.py +1 -374
- package/src/deepscientist/connector_runtime.py +2 -0
- package/src/deepscientist/daemon/api/handlers.py +295 -5
- package/src/deepscientist/daemon/api/router.py +16 -1
- package/src/deepscientist/daemon/app.py +1130 -61
- package/src/deepscientist/doctor.py +5 -2
- package/src/deepscientist/gitops/diff.py +120 -29
- package/src/deepscientist/lingzhu_support.py +1 -182
- package/src/deepscientist/mcp/server.py +14 -5
- package/src/deepscientist/prompts/builder.py +29 -1
- package/src/deepscientist/qq_profiles.py +1 -196
- package/src/deepscientist/quest/node_traces.py +152 -2
- package/src/deepscientist/quest/service.py +169 -43
- package/src/deepscientist/quest/stage_views.py +172 -9
- package/src/deepscientist/registries/baseline.py +56 -4
- package/src/deepscientist/runners/codex.py +55 -3
- package/src/deepscientist/weixin_support.py +1 -0
- package/src/prompts/connectors/lingzhu.md +3 -1
- package/src/prompts/connectors/weixin.md +230 -0
- package/src/prompts/system.md +9 -0
- package/src/skills/idea/SKILL.md +16 -0
- package/src/skills/idea/references/literature-survey-template.md +24 -0
- package/src/skills/idea/references/related-work-playbook.md +4 -0
- package/src/skills/idea/references/selection-gate.md +9 -0
- package/src/skills/write/SKILL.md +1 -1
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/{AiManusChatView-m2FNtwbn.js → AiManusChatView-D0mTXG4-.js} +156 -48
- package/src/ui/dist/assets/{AnalysisPlugin-BMTF8EGL.js → AnalysisPlugin-Db0cTXxm.js} +1 -1
- package/src/ui/dist/assets/{CliPlugin-BEOWgxCI.js → CliPlugin-DrV8je02.js} +164 -9
- package/src/ui/dist/assets/{CodeEditorPlugin-BCXvjqmb.js → CodeEditorPlugin-QXMSCH71.js} +8 -8
- package/src/ui/dist/assets/{CodeViewerPlugin-DaJcy3nD.js → CodeViewerPlugin-7hhtWj_E.js} +5 -5
- package/src/ui/dist/assets/{DocViewerPlugin-ByfeIq4K.js → DocViewerPlugin-BWMSnRJe.js} +3 -3
- package/src/ui/dist/assets/{GitDiffViewerPlugin-Cksf3VZ-.js → GitDiffViewerPlugin-7J9h9Vy_.js} +20 -21
- package/src/ui/dist/assets/{ImageViewerPlugin-CFz-OsTS.js → ImageViewerPlugin-CHJl_0lr.js} +5 -5
- package/src/ui/dist/assets/{LabCopilotPanel-CJ1cJzoX.js → LabCopilotPanel-1qSow1es.js} +11 -11
- package/src/ui/dist/assets/{LabPlugin-BF3dVJwa.js → LabPlugin-eQpPPCEp.js} +2 -1
- package/src/ui/dist/assets/{LatexPlugin-DDkwZ6Sj.js → LatexPlugin-BwRfi89Z.js} +7 -7
- package/src/ui/dist/assets/{MarkdownViewerPlugin-HAuvurcT.js → MarkdownViewerPlugin-836PVQWV.js} +4 -4
- package/src/ui/dist/assets/{MarketplacePlugin-BtoTYy2C.js → MarketplacePlugin-C2y_556i.js} +3 -3
- package/src/ui/dist/assets/{NotebookEditor-CSJYx7b-.js → NotebookEditor-BRzJbGsn.js} +12 -12
- package/src/ui/dist/assets/{NotebookEditor-DQgRezm_.js → NotebookEditor-DIX7Mlzu.js} +1 -1
- package/src/ui/dist/assets/{PdfLoader-DPa_-fv6.js → PdfLoader-DzRaTAlq.js} +14 -7
- package/src/ui/dist/assets/{PdfMarkdownPlugin-BZpXOEjm.js → PdfMarkdownPlugin-DZUfIUnp.js} +73 -6
- package/src/ui/dist/assets/{PdfViewerPlugin-BT8a6wGR.js → PdfViewerPlugin-BwtICzue.js} +103 -34
- package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
- package/src/ui/dist/assets/{SearchPlugin-D_blveZi.js → SearchPlugin-DHeIAMsx.js} +1 -1
- package/src/ui/dist/assets/{TextViewerPlugin-Btx0M3hX.js → TextViewerPlugin-C3tCmFox.js} +5 -4
- package/src/ui/dist/assets/{VNCViewer-DImJO4rO.js → VNCViewer-CQsKVm3t.js} +10 -10
- package/src/ui/dist/assets/bot-BEA2vWuK.js +21 -0
- package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
- package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
- package/src/ui/dist/assets/{code-BUfXGJSl.js → code-XfbSR8K2.js} +1 -1
- package/src/ui/dist/assets/{file-content-VqamwI3X.js → file-content-BjxNaIfy.js} +1 -1
- package/src/ui/dist/assets/{file-diff-panel-C_wOoS7a.js → file-diff-panel-D_lLVQk0.js} +1 -1
- package/src/ui/dist/assets/{file-socket-D2bTuMVP.js → file-socket-D9x_5vlY.js} +1 -1
- package/src/ui/dist/assets/{image-BZkGJ4mM.js → image-BhWT33W1.js} +1 -1
- package/src/ui/dist/assets/{index-DdRW6RMJ.js → index--c4iXtuy.js} +12 -12
- package/src/ui/dist/assets/{index-CxkvSeKw.js → index-BDxipwrC.js} +2 -2
- package/src/ui/dist/assets/{index-DjggJovS.js → index-DZTZ8mWP.js} +14934 -9613
- package/src/ui/dist/assets/{index-DXZ1daiJ.css → index-Dqj-Mjb4.css} +2 -13
- package/src/ui/dist/assets/index-PJbSbPTy.js +25 -0
- package/src/ui/dist/assets/{monaco-DHMc7kKM.js → monaco-K8izTGgo.js} +1 -1
- package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DfBors6y.js} +16 -1
- package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
- package/src/ui/dist/assets/{popover-B85oCgCS.js → popover-yFK1J4fL.js} +1 -1
- package/src/ui/dist/assets/{project-sync-DOMCcPac.js → project-sync-PENr2zcz.js} +1 -74
- package/src/ui/dist/assets/select-CAbJDfYv.js +1690 -0
- package/src/ui/dist/assets/{sigma-BO2rQrl3.js → sigma-DEuYJqTl.js} +1 -1
- package/src/ui/dist/assets/{index-D9QIGcmc.js → square-check-big-omoSUmcd.js} +2 -13
- package/src/ui/dist/assets/{trash-BsVEH_dV.js → trash--F119N47.js} +1 -1
- package/src/ui/dist/assets/{useCliAccess-b8L6JuZm.js → useCliAccess-D31UR23I.js} +1 -1
- package/src/ui/dist/assets/{useFileDiffOverlay-BY7uA9hV.js → useFileDiffOverlay-BH6KcMzq.js} +1 -1
- package/src/ui/dist/assets/{wrap-text-BwyVuUIK.js → wrap-text-CZ613PM5.js} +1 -1
- package/src/ui/dist/assets/{zoom-out-RDpLugQP.js → zoom-out-BgDLAv3z.js} +1 -1
- package/src/ui/dist/index.html +2 -2
- package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
- package/src/ui/dist/assets/AutoFigurePlugin-DxPdMUNb.js +0 -8149
- package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
- package/src/ui/dist/assets/Stepper-DH2k75Vo.js +0 -158
- package/src/ui/dist/assets/bibtex-B-Hqu0Sg.js +0 -189
- package/src/ui/dist/assets/file-utils--zJCPN1i.js +0 -109
- package/src/ui/dist/assets/message-square-FUIPIhU2.js +0 -16
- package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
- 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
|
-
|
|
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)
|