@researai/deepscientist 1.5.0 → 1.5.1
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/AGENTS.md +26 -0
- package/README.md +19 -179
- package/assets/connectors/lingzhu/openclaw-bridge/README.md +124 -0
- package/assets/connectors/lingzhu/openclaw-bridge/index.ts +162 -0
- package/assets/connectors/lingzhu/openclaw-bridge/openclaw.plugin.json +145 -0
- package/assets/connectors/lingzhu/openclaw-bridge/package.json +35 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/cli.ts +180 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/config.ts +196 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/debug-log.ts +111 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/events.ts +4 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/http-handler.ts +1133 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/image-cache.ts +75 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/lingzhu-tools.ts +246 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/transform.ts +541 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/types.ts +131 -0
- package/assets/connectors/lingzhu/openclaw-bridge/tsconfig.json +14 -0
- package/assets/connectors/lingzhu/openclaw.lingzhu.config.template.json +39 -0
- package/bin/ds.js +233 -53
- package/docs/en/00_QUICK_START.md +134 -0
- package/docs/en/01_SETTINGS_REFERENCE.md +1104 -0
- package/docs/en/02_START_RESEARCH_GUIDE.md +404 -0
- package/docs/en/03_QQ_CONNECTOR_GUIDE.md +325 -0
- package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +216 -0
- package/docs/en/05_TUI_GUIDE.md +141 -0
- package/docs/en/06_RUNTIME_AND_CANVAS.md +679 -0
- package/docs/en/07_MEMORY_AND_MCP.md +253 -0
- package/docs/en/08_FIGURE_STYLE_GUIDE.md +97 -0
- package/docs/en/09_DOCTOR.md +108 -0
- package/docs/en/90_ARCHITECTURE.md +245 -0
- package/docs/en/91_DEVELOPMENT.md +195 -0
- package/docs/en/99_ACKNOWLEDGEMENTS.md +29 -0
- package/docs/zh/00_QUICK_START.md +134 -0
- package/docs/zh/01_SETTINGS_REFERENCE.md +1137 -0
- package/docs/zh/02_START_RESEARCH_GUIDE.md +414 -0
- package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +324 -0
- package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +230 -0
- package/docs/zh/05_TUI_GUIDE.md +128 -0
- package/docs/zh/06_RUNTIME_AND_CANVAS.md +271 -0
- package/docs/zh/07_MEMORY_AND_MCP.md +235 -0
- package/docs/zh/08_FIGURE_STYLE_GUIDE.md +97 -0
- package/docs/zh/09_DOCTOR.md +112 -0
- package/docs/zh/99_ACKNOWLEDGEMENTS.md +29 -0
- package/install.sh +32 -8
- package/package.json +4 -2
- package/pyproject.toml +1 -1
- package/src/deepscientist/artifact/guidance.py +9 -2
- package/src/deepscientist/artifact/service.py +482 -22
- package/src/deepscientist/bash_exec/monitor.py +27 -5
- package/src/deepscientist/bash_exec/runtime.py +639 -0
- package/src/deepscientist/bash_exec/service.py +99 -16
- package/src/deepscientist/bridges/base.py +3 -0
- package/src/deepscientist/bridges/connectors.py +292 -13
- package/src/deepscientist/channels/qq.py +19 -2
- package/src/deepscientist/channels/relay.py +1 -0
- package/src/deepscientist/cli.py +32 -25
- package/src/deepscientist/config/models.py +28 -2
- package/src/deepscientist/config/service.py +201 -6
- package/src/deepscientist/connector_runtime.py +2 -0
- package/src/deepscientist/daemon/api/handlers.py +50 -5
- package/src/deepscientist/daemon/api/router.py +1 -0
- package/src/deepscientist/daemon/app.py +442 -15
- package/src/deepscientist/doctor.py +444 -0
- package/src/deepscientist/home.py +1 -0
- package/src/deepscientist/latex_runtime.py +17 -4
- package/src/deepscientist/lingzhu_support.py +182 -0
- package/src/deepscientist/mcp/server.py +49 -2
- package/src/deepscientist/prompts/builder.py +181 -58
- package/src/deepscientist/quest/layout.py +1 -0
- package/src/deepscientist/quest/service.py +63 -2
- package/src/deepscientist/quest/stage_views.py +19 -1
- package/src/deepscientist/runtime_tools/__init__.py +16 -0
- package/src/deepscientist/runtime_tools/builtins.py +19 -0
- package/src/deepscientist/runtime_tools/models.py +29 -0
- package/src/deepscientist/runtime_tools/registry.py +40 -0
- package/src/deepscientist/runtime_tools/service.py +59 -0
- package/src/deepscientist/runtime_tools/tinytex.py +25 -0
- package/src/deepscientist/tinytex.py +276 -0
- package/src/prompts/connectors/lingzhu.md +12 -0
- package/src/prompts/connectors/qq.md +121 -0
- package/src/prompts/system.md +177 -33
- package/src/skills/analysis-campaign/SKILL.md +22 -6
- package/src/skills/baseline/SKILL.md +5 -4
- package/src/skills/decision/SKILL.md +4 -3
- package/src/skills/experiment/SKILL.md +5 -4
- package/src/skills/finalize/SKILL.md +5 -4
- package/src/skills/idea/SKILL.md +5 -4
- package/src/skills/intake-audit/SKILL.md +277 -0
- package/src/skills/intake-audit/references/state-audit-template.md +41 -0
- package/src/skills/rebuttal/SKILL.md +407 -0
- package/src/skills/rebuttal/references/action-plan-template.md +63 -0
- package/src/skills/rebuttal/references/evidence-update-template.md +30 -0
- package/src/skills/rebuttal/references/response-letter-template.md +113 -0
- package/src/skills/rebuttal/references/review-matrix-template.md +55 -0
- package/src/skills/review/SKILL.md +293 -0
- package/src/skills/review/references/experiment-todo-template.md +29 -0
- package/src/skills/review/references/review-report-template.md +83 -0
- package/src/skills/review/references/revision-log-template.md +40 -0
- package/src/skills/scout/SKILL.md +5 -4
- package/src/skills/write/SKILL.md +7 -3
- package/src/tui/dist/components/WelcomePanel.js +17 -43
- package/src/tui/dist/components/messages/BashExecOperationMessage.js +3 -2
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/{AiManusChatView-7v-dHngU.js → AiManusChatView-w5lF2Ttt.js} +109 -575
- package/src/ui/dist/assets/{AnalysisPlugin-B_Xmz-KE.js → AnalysisPlugin-DJOED79I.js} +1 -1
- package/src/ui/dist/assets/{AutoFigurePlugin-Cko-0tm1.js → AutoFigurePlugin-DaG61Y0M.js} +63 -8
- package/src/ui/dist/assets/{CliPlugin-BsU0ht7q.js → CliPlugin-CV4LqUB_.js} +43 -609
- package/src/ui/dist/assets/{CodeEditorPlugin-DcMMP0Rt.js → CodeEditorPlugin-DylfAea4.js} +8 -8
- package/src/ui/dist/assets/{CodeViewerPlugin-BqoQ5QyY.js → CodeViewerPlugin-F7saY0LM.js} +5 -5
- package/src/ui/dist/assets/{DocViewerPlugin-D7eHNhU6.js → DocViewerPlugin-COP0c7jf.js} +3 -3
- package/src/ui/dist/assets/{GitDiffViewerPlugin-DLJN42T5.js → GitDiffViewerPlugin-CAS05pT9.js} +1 -1
- package/src/ui/dist/assets/{ImageViewerPlugin-gJMV7MOu.js → ImageViewerPlugin-Bco1CN_w.js} +5 -6
- package/src/ui/dist/assets/{LabCopilotPanel-B857sfxP.js → LabCopilotPanel-CvMlCD99.js} +12 -15
- package/src/ui/dist/assets/LabPlugin-BYankkE4.js +2676 -0
- package/src/ui/dist/assets/LabPlugin-D9jVIo0A.css +2698 -0
- package/src/ui/dist/assets/{LatexPlugin-DWKEo-Wj.js → LatexPlugin-LDSMR-t-.js} +16 -16
- package/src/ui/dist/assets/{MarkdownViewerPlugin-DBzoEmhv.js → MarkdownViewerPlugin-B7o80jgm.js} +4 -4
- package/src/ui/dist/assets/{MarketplacePlugin-DoHc-8vo.js → MarketplacePlugin-CM6ZOcpC.js} +3 -3
- package/src/ui/dist/assets/{NotebookEditor-CKjKH-yS.js → NotebookEditor-Dc61cXmK.js} +3 -3
- package/src/ui/dist/assets/{PdfLoader-zFoL0VPo.js → PdfLoader-DWowuQwx.js} +1 -1
- package/src/ui/dist/assets/{PdfMarkdownPlugin-DXPaL9Nt.js → PdfMarkdownPlugin-BsJM1q_a.js} +3 -3
- package/src/ui/dist/assets/{PdfViewerPlugin-DhK8qCFp.js → PdfViewerPlugin-DB2eEEFQ.js} +10 -10
- package/src/ui/dist/assets/{SearchPlugin-CdSi6krf.js → SearchPlugin-CraThSvt.js} +1 -1
- package/src/ui/dist/assets/{Stepper-V-WiDQJl.js → Stepper-CgocRTPq.js} +1 -1
- package/src/ui/dist/assets/{TextViewerPlugin-hIs1Efiu.js → TextViewerPlugin-B1JGhKtd.js} +4 -4
- package/src/ui/dist/assets/{VNCViewer-DG8b0q2X.js → VNCViewer-CclFC7FM.js} +9 -10
- package/src/ui/dist/assets/{bibtex-HDac6fVW.js → bibtex-D3IKsMl7.js} +1 -1
- package/src/ui/dist/assets/{code-BnBeNxBc.js → code-BP37Xx0p.js} +1 -1
- package/src/ui/dist/assets/{file-content-IRQ3jHb8.js → file-content-BAJSu-9r.js} +1 -1
- package/src/ui/dist/assets/{file-diff-panel-DZoQ9I6r.js → file-diff-panel-DUGeCTuy.js} +1 -1
- package/src/ui/dist/assets/{file-socket-BMCdLc-P.js → file-socket-CXc1Ojf7.js} +1 -1
- package/src/ui/dist/assets/{file-utils-CltILB3w.js → file-utils-2J21jt7M.js} +1 -1
- package/src/ui/dist/assets/{image-Boe6ffhu.js → image-CMMmgvcn.js} +1 -1
- package/src/ui/dist/assets/{index-BlplpvE1.js → index-BaVumsQT.js} +2 -2
- package/src/ui/dist/assets/{index-DZqJ-qAM.js → index-CWgMgpow.js} +60 -2154
- package/src/ui/dist/assets/{index-DO43pFZP.js → index-DmwmJmbW.js} +6372 -8434
- package/src/ui/dist/assets/{index-Bq2bvfkl.css → index-KGt-z-dD.css} +225 -2920
- package/src/ui/dist/assets/{index-2Zf65FZt.js → index-s7aHnNQ4.js} +1 -1
- package/src/ui/dist/assets/{message-square-mUHn_Ssb.js → message-square-CQRfX0Am.js} +1 -1
- package/src/ui/dist/assets/{monaco-fe0arNEU.js → monaco-B4TbdsrF.js} +1 -1
- package/src/ui/dist/assets/{popover-D_7i19qU.js → popover-B8Rokodk.js} +1 -1
- package/src/ui/dist/assets/{project-sync-DyVGrU7H.js → project-sync-D_i96KH4.js} +2 -8
- package/src/ui/dist/assets/{sigma-BzazRyxQ.js → sigma-D12PnzCN.js} +1 -1
- package/src/ui/dist/assets/{tooltip-DN_yjHFH.js → tooltip-B6YrI4aJ.js} +1 -1
- package/src/ui/dist/assets/trash-Bc8jGp0V.js +32 -0
- package/src/ui/dist/assets/{useCliAccess-DV2L2Qxy.js → useCliAccess-mXVCYSZ-.js} +12 -42
- package/src/ui/dist/assets/{useFileDiffOverlay-DyTj-p_V.js → useFileDiffOverlay-Bg6b9H9K.js} +1 -1
- package/src/ui/dist/assets/{wrap-text-ozYHtUwq.js → wrap-text-Drh5GEnL.js} +1 -1
- package/src/ui/dist/assets/{zoom-out-BN9MUyCQ.js → zoom-out-CJj9DZLn.js} +1 -1
- package/src/ui/dist/index.html +2 -2
- package/assets/fonts/Inter-Variable.ttf +0 -0
- package/assets/fonts/NotoSerifSC-Regular-C94HN_ZN.ttf +0 -0
- package/assets/fonts/NunitoSans-Variable.ttf +0 -0
- package/assets/fonts/Satoshi-Medium-ByP-Zb-9.woff2 +0 -0
- package/assets/fonts/SourceSans3-Variable.ttf +0 -0
- package/assets/fonts/ds-fonts.css +0 -83
- package/src/ui/dist/assets/Inter-Variable-VF2RPR_K.ttf +0 -0
- package/src/ui/dist/assets/LabPlugin-bL7rpic8.js +0 -43
- package/src/ui/dist/assets/NotoSerifSC-Regular-C94HN_ZN-C94HN_ZN.ttf +0 -0
- package/src/ui/dist/assets/NunitoSans-Variable-B_ZymHAd.ttf +0 -0
- package/src/ui/dist/assets/Satoshi-Medium-ByP-Zb-9-GkA34YXu.woff2 +0 -0
- package/src/ui/dist/assets/SourceSans3-Variable-CD-WOsSK.ttf +0 -0
- package/src/ui/dist/assets/info-CcsK_htA.js +0 -18
- package/src/ui/dist/assets/user-plus-BusDx-hF.js +0 -79
|
@@ -8,6 +8,7 @@ import shutil
|
|
|
8
8
|
import signal
|
|
9
9
|
import subprocess
|
|
10
10
|
import sys
|
|
11
|
+
import tempfile
|
|
11
12
|
import threading
|
|
12
13
|
import time
|
|
13
14
|
from pathlib import Path
|
|
@@ -15,6 +16,7 @@ from typing import Any
|
|
|
15
16
|
|
|
16
17
|
from ..mcp.context import McpContext
|
|
17
18
|
from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, utc_now
|
|
19
|
+
from .runtime import TerminalRuntimeManager
|
|
18
20
|
|
|
19
21
|
BASH_STATUS_MARKER_PREFIX = "__DS_BASH_STATUS__"
|
|
20
22
|
BASH_CARRIAGE_RETURN_PREFIX = "__DS_BASH_CR__"
|
|
@@ -29,11 +31,16 @@ INPUT_ESCAPE_SEQUENCE_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[@-_]")
|
|
|
29
31
|
|
|
30
32
|
def _atomic_write_json(path: Path, payload: Any) -> None:
|
|
31
33
|
ensure_dir(path.parent)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=False) + "\n",
|
|
34
|
+
with tempfile.NamedTemporaryFile(
|
|
35
|
+
"w",
|
|
35
36
|
encoding="utf-8",
|
|
36
|
-
|
|
37
|
+
dir=path.parent,
|
|
38
|
+
prefix=f"{path.name}.",
|
|
39
|
+
suffix=".tmp",
|
|
40
|
+
delete=False,
|
|
41
|
+
) as handle:
|
|
42
|
+
handle.write(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=False) + "\n")
|
|
43
|
+
temp_path = Path(handle.name)
|
|
37
44
|
temp_path.replace(path)
|
|
38
45
|
|
|
39
46
|
|
|
@@ -87,6 +94,7 @@ class BashExecService:
|
|
|
87
94
|
self.home = home
|
|
88
95
|
self._summary_cache_lock = threading.Lock()
|
|
89
96
|
self._summary_cache: dict[str, dict[str, Any]] = {}
|
|
97
|
+
self._terminal_runtime_manager = TerminalRuntimeManager(home)
|
|
90
98
|
|
|
91
99
|
def _quest_root(self, quest_id: str) -> Path:
|
|
92
100
|
return self.home / "quests" / quest_id
|
|
@@ -130,6 +138,9 @@ class BashExecService:
|
|
|
130
138
|
def terminal_rc_path(self, quest_root: Path, bash_id: str) -> Path:
|
|
131
139
|
return self.session_dir(quest_root, bash_id) / "terminal.rc"
|
|
132
140
|
|
|
141
|
+
def prompt_events_path(self, quest_root: Path, bash_id: str) -> Path:
|
|
142
|
+
return self.session_dir(quest_root, bash_id) / "prompt-events.log"
|
|
143
|
+
|
|
133
144
|
def monitor_log_path(self, quest_root: Path, bash_id: str) -> Path:
|
|
134
145
|
return self.session_dir(quest_root, bash_id) / "monitor.log"
|
|
135
146
|
|
|
@@ -358,9 +369,27 @@ class BashExecService:
|
|
|
358
369
|
status = _coerce_session_status(meta.get("status"))
|
|
359
370
|
if status in TERMINAL_STATUSES:
|
|
360
371
|
return self._session_payload(quest_root, meta)
|
|
372
|
+
kind = _normalize_string(meta.get("kind")).lower()
|
|
373
|
+
if kind == "terminal":
|
|
374
|
+
runtime = self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
|
|
375
|
+
if runtime is not None:
|
|
376
|
+
return self._session_payload(quest_root, read_json(meta_path, meta) or meta)
|
|
361
377
|
monitor_pid = meta.get("monitor_pid")
|
|
362
378
|
process_pid = meta.get("process_pid")
|
|
363
|
-
if
|
|
379
|
+
if kind == "terminal" and _is_process_alive(process_pid):
|
|
380
|
+
process_group_id = meta.get("process_group_id")
|
|
381
|
+
if isinstance(process_group_id, int) and process_group_id > 0:
|
|
382
|
+
try:
|
|
383
|
+
os.killpg(process_group_id, signal.SIGTERM)
|
|
384
|
+
except ProcessLookupError:
|
|
385
|
+
pass
|
|
386
|
+
elif isinstance(process_pid, int) and process_pid > 0:
|
|
387
|
+
try:
|
|
388
|
+
os.kill(process_pid, signal.SIGTERM)
|
|
389
|
+
except ProcessLookupError:
|
|
390
|
+
pass
|
|
391
|
+
time.sleep(0.05)
|
|
392
|
+
if kind != "terminal" and (_is_process_alive(process_pid) or _is_process_alive(monitor_pid)):
|
|
364
393
|
return self._session_payload(quest_root, meta)
|
|
365
394
|
stop_reason = _normalize_string(meta.get("stop_reason"))
|
|
366
395
|
meta["status"] = "terminated" if stop_reason else "failed"
|
|
@@ -509,12 +538,16 @@ class BashExecService:
|
|
|
509
538
|
meta["stopped_by_user_id"] = request_payload["user_id"]
|
|
510
539
|
meta["updated_at"] = utc_now()
|
|
511
540
|
self._write_meta(quest_root, bash_id, meta)
|
|
512
|
-
|
|
513
|
-
if
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
541
|
+
runtime = self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
|
|
542
|
+
if runtime is not None:
|
|
543
|
+
runtime.stop(reason=request_payload["reason"], force=False)
|
|
544
|
+
else:
|
|
545
|
+
process_group_id = meta.get("process_group_id")
|
|
546
|
+
if isinstance(process_group_id, int) and process_group_id > 0:
|
|
547
|
+
try:
|
|
548
|
+
os.killpg(process_group_id, signal.SIGTERM)
|
|
549
|
+
except ProcessLookupError:
|
|
550
|
+
pass
|
|
518
551
|
return self._session_payload(quest_root, meta)
|
|
519
552
|
|
|
520
553
|
def _build_initial_meta(
|
|
@@ -724,7 +757,12 @@ class BashExecService:
|
|
|
724
757
|
resolved_quest_id = _normalize_string(quest_id) or resolved_quest_root.name
|
|
725
758
|
try:
|
|
726
759
|
session = self.reconcile_session(resolved_quest_root, bash_id)
|
|
727
|
-
|
|
760
|
+
runtime = self._terminal_runtime_manager.get_runtime(resolved_quest_root, bash_id)
|
|
761
|
+
if (
|
|
762
|
+
_normalize_string(session.get("kind")).lower() == "terminal"
|
|
763
|
+
and _normalize_string(session.get("status")).lower() not in TERMINAL_STATUSES
|
|
764
|
+
and runtime is not None
|
|
765
|
+
):
|
|
728
766
|
return session
|
|
729
767
|
except FileNotFoundError:
|
|
730
768
|
session = None
|
|
@@ -747,6 +785,7 @@ class BashExecService:
|
|
|
747
785
|
self.log_path(resolved_quest_root, bash_id).touch()
|
|
748
786
|
self.input_path(resolved_quest_root, bash_id).touch()
|
|
749
787
|
self.history_path(resolved_quest_root, bash_id).touch()
|
|
788
|
+
self.prompt_events_path(resolved_quest_root, bash_id).touch()
|
|
750
789
|
_atomic_write_json(
|
|
751
790
|
self.input_cursor_path(resolved_quest_root, bash_id),
|
|
752
791
|
{"offset": len(read_jsonl(self.input_path(resolved_quest_root, bash_id))), "updated_at": utc_now()},
|
|
@@ -759,9 +798,9 @@ class BashExecService:
|
|
|
759
798
|
terminal_rc_path.write_text(
|
|
760
799
|
"\n".join(
|
|
761
800
|
[
|
|
762
|
-
"PS1=''",
|
|
763
|
-
"PS2=''",
|
|
764
|
-
'PROMPT_COMMAND=\'printf "__DS_TERMINAL_PROMPT__ cwd=%q ts=%s
|
|
801
|
+
"PS1='\\w$ '",
|
|
802
|
+
"PS2='> '",
|
|
803
|
+
'PROMPT_COMMAND=\'printf "__DS_TERMINAL_PROMPT__ cwd=%q ts=%s\\n" "$PWD" "$(date -u +%FT%TZ)" >> "${DS_TERMINAL_PROMPT_PATH}"\'',
|
|
765
804
|
"bind 'set enable-bracketed-paste off' >/dev/null 2>&1 || true",
|
|
766
805
|
"",
|
|
767
806
|
]
|
|
@@ -775,6 +814,7 @@ class BashExecService:
|
|
|
775
814
|
env_payload = {
|
|
776
815
|
"TERM": "xterm-256color",
|
|
777
816
|
"COLORTERM": "truecolor",
|
|
817
|
+
"DS_TERMINAL_PROMPT_PATH": str(self.prompt_events_path(resolved_quest_root, bash_id)),
|
|
778
818
|
}
|
|
779
819
|
command = f"exec bash --noprofile --rcfile {shlex.quote(str(terminal_rc_path))} -i"
|
|
780
820
|
resolved_label = _normalize_string(label) or previous_label
|
|
@@ -803,10 +843,16 @@ class BashExecService:
|
|
|
803
843
|
"started_at": meta["started_at"],
|
|
804
844
|
},
|
|
805
845
|
)
|
|
806
|
-
meta
|
|
846
|
+
meta = self._terminal_runtime_manager.ensure_runtime(
|
|
807
847
|
quest_root=resolved_quest_root,
|
|
808
848
|
bash_id=bash_id,
|
|
849
|
+
meta_path=self.meta_path(resolved_quest_root, bash_id),
|
|
850
|
+
log_path=self.log_path(resolved_quest_root, bash_id),
|
|
851
|
+
terminal_log_path=self.terminal_log_path(resolved_quest_root, bash_id),
|
|
852
|
+
prompt_events_path=self.prompt_events_path(resolved_quest_root, bash_id),
|
|
809
853
|
env_payload=env_payload,
|
|
854
|
+
command=command,
|
|
855
|
+
cwd=target_cwd,
|
|
810
856
|
)
|
|
811
857
|
meta["updated_at"] = utc_now()
|
|
812
858
|
self._write_meta(resolved_quest_root, bash_id, meta)
|
|
@@ -870,6 +916,10 @@ class BashExecService:
|
|
|
870
916
|
status = _normalize_string(session.get("status")).lower()
|
|
871
917
|
if status in TERMINAL_STATUSES:
|
|
872
918
|
raise ValueError("terminal_session_inactive")
|
|
919
|
+
runtime = self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
|
|
920
|
+
if runtime is None:
|
|
921
|
+
raise ValueError("terminal_runtime_inactive")
|
|
922
|
+
runtime.write_input(normalized_data)
|
|
873
923
|
|
|
874
924
|
entry = {
|
|
875
925
|
"input_id": generate_id("tin"),
|
|
@@ -909,6 +959,39 @@ class BashExecService:
|
|
|
909
959
|
"completed_commands": completed,
|
|
910
960
|
}
|
|
911
961
|
|
|
962
|
+
def resize_terminal_session(self, quest_root: Path, bash_id: str, *, cols: int, rows: int) -> bool:
|
|
963
|
+
runtime = self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
|
|
964
|
+
if runtime is None:
|
|
965
|
+
return False
|
|
966
|
+
runtime.resize(cols, rows)
|
|
967
|
+
return True
|
|
968
|
+
|
|
969
|
+
def issue_terminal_attach_token(self, quest_root: Path, bash_id: str, *, ttl_seconds: int = 60) -> dict[str, Any]:
|
|
970
|
+
runtime = self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
|
|
971
|
+
if runtime is None:
|
|
972
|
+
raise ValueError("terminal_runtime_inactive")
|
|
973
|
+
token = self._terminal_runtime_manager.issue_attach_token(
|
|
974
|
+
quest_root,
|
|
975
|
+
bash_id,
|
|
976
|
+
ttl_seconds=ttl_seconds,
|
|
977
|
+
)
|
|
978
|
+
return {
|
|
979
|
+
"token": token.token,
|
|
980
|
+
"expires_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(token.expires_at)),
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
def get_terminal_runtime(self, quest_root: Path, bash_id: str):
|
|
984
|
+
return self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
|
|
985
|
+
|
|
986
|
+
def consume_terminal_attach_token(self, token: str):
|
|
987
|
+
return self._terminal_runtime_manager.consume_attach_token(token)
|
|
988
|
+
|
|
989
|
+
def resolve_terminal_attach_token(self, token: str):
|
|
990
|
+
return self._terminal_runtime_manager.resolve_attach_token(token)
|
|
991
|
+
|
|
992
|
+
def shutdown(self) -> None:
|
|
993
|
+
self._terminal_runtime_manager.shutdown()
|
|
994
|
+
|
|
912
995
|
def terminal_restore_payload(
|
|
913
996
|
self,
|
|
914
997
|
quest_root: Path,
|
|
@@ -44,6 +44,7 @@ class BaseConnectorBridge:
|
|
|
44
44
|
"message": self.render_text(payload.get("text"), payload.get("attachments")),
|
|
45
45
|
"reply_to_message_id": payload.get("reply_to_message_id"),
|
|
46
46
|
"attachments": payload.get("attachments") or [],
|
|
47
|
+
"connector_hints": payload.get("connector_hints") or {},
|
|
47
48
|
"quest_id": payload.get("quest_id"),
|
|
48
49
|
"quest_root": payload.get("quest_root"),
|
|
49
50
|
"importance": payload.get("importance"),
|
|
@@ -96,6 +97,8 @@ class BaseConnectorBridge:
|
|
|
96
97
|
if isinstance(item, str):
|
|
97
98
|
attachment_lines.append(f"- {item}")
|
|
98
99
|
elif isinstance(item, dict):
|
|
100
|
+
if str(item.get("path_error") or "").strip():
|
|
101
|
+
continue
|
|
99
102
|
candidate = item.get("label") or item.get("path") or item.get("url")
|
|
100
103
|
if candidate:
|
|
101
104
|
attachment_lines.append(f"- {candidate}")
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import base64
|
|
3
4
|
import json
|
|
4
5
|
import time
|
|
5
6
|
from hashlib import sha256
|
|
6
7
|
from hmac import new as hmac_new
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Any
|
|
10
|
+
from urllib.error import HTTPError
|
|
9
11
|
from urllib.request import Request, urlopen
|
|
10
12
|
|
|
11
13
|
from ..shared import append_jsonl, ensure_dir, utc_now
|
|
@@ -396,6 +398,8 @@ class DiscordConnectorBridge(BaseConnectorBridge):
|
|
|
396
398
|
class QQConnectorBridge(BaseConnectorBridge):
|
|
397
399
|
name = "qq"
|
|
398
400
|
_token_cache: dict[str, dict[str, float | str]] = {}
|
|
401
|
+
_IMAGE_FILE_TYPE = 1
|
|
402
|
+
_FILE_FILE_TYPE = 4
|
|
399
403
|
|
|
400
404
|
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:
|
|
401
405
|
return BridgeWebhookResult(
|
|
@@ -412,18 +416,21 @@ class QQConnectorBridge(BaseConnectorBridge):
|
|
|
412
416
|
|
|
413
417
|
def format_outbound(self, payload: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
|
414
418
|
target = self.extract_target(payload.get("conversation_id"))
|
|
415
|
-
message_body: dict[str, Any] = {
|
|
416
|
-
"content": self.render_text(payload.get("text"), payload.get("attachments")),
|
|
417
|
-
"msg_type": 0,
|
|
418
|
-
}
|
|
419
419
|
reply_to = str(payload.get("reply_to_message_id") or "").strip()
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
420
|
+
requested_render_mode = self._requested_render_mode(payload)
|
|
421
|
+
render_mode, render_mode_warning = self._effective_render_mode(requested_render_mode, config)
|
|
422
|
+
native_attachments, residual_attachments, attachment_issues = self._partition_native_attachments(payload.get("attachments"), config)
|
|
423
|
+
text = self.render_text(payload.get("text"), residual_attachments)
|
|
423
424
|
return {
|
|
424
425
|
"chat_type": target["chat_type"],
|
|
425
426
|
"chat_id": target["chat_id"],
|
|
426
|
-
"
|
|
427
|
+
"reply_to_message_id": reply_to or None,
|
|
428
|
+
"requested_render_mode": requested_render_mode,
|
|
429
|
+
"render_mode": render_mode,
|
|
430
|
+
"render_mode_warning": render_mode_warning,
|
|
431
|
+
"text": text,
|
|
432
|
+
"native_attachments": native_attachments,
|
|
433
|
+
"attachment_issues": attachment_issues,
|
|
427
434
|
}
|
|
428
435
|
|
|
429
436
|
def deliver_direct(self, payload: dict[str, Any], config: dict[str, Any]) -> dict[str, Any] | None:
|
|
@@ -437,17 +444,289 @@ class QQConnectorBridge(BaseConnectorBridge):
|
|
|
437
444
|
if chat_type not in {"direct", "group"} or not chat_id:
|
|
438
445
|
return None
|
|
439
446
|
access_token = self._access_token(app_id, app_secret)
|
|
440
|
-
|
|
447
|
+
reply_to = str(formatted.get("reply_to_message_id") or "").strip() or None
|
|
448
|
+
text = str(formatted.get("text") or "").strip()
|
|
449
|
+
render_mode = str(formatted.get("render_mode") or "plain").strip().lower()
|
|
450
|
+
native_attachments = [dict(item) for item in (formatted.get("native_attachments") or []) if isinstance(item, dict)]
|
|
451
|
+
attachment_issues = [dict(item) for item in (formatted.get("attachment_issues") or []) if isinstance(item, dict)]
|
|
452
|
+
parts: list[dict[str, Any]] = []
|
|
453
|
+
warnings: list[str] = []
|
|
454
|
+
if str(formatted.get("render_mode_warning") or "").strip():
|
|
455
|
+
warnings.append(str(formatted.get("render_mode_warning")).strip())
|
|
456
|
+
for issue in attachment_issues:
|
|
457
|
+
error = str(issue.get("error") or "").strip()
|
|
458
|
+
if error:
|
|
459
|
+
warnings.append(error)
|
|
460
|
+
if text or not native_attachments:
|
|
461
|
+
text_result = self._send_text_message(
|
|
462
|
+
access_token=access_token,
|
|
463
|
+
chat_type=chat_type,
|
|
464
|
+
chat_id=chat_id,
|
|
465
|
+
text=text,
|
|
466
|
+
reply_to_message_id=reply_to,
|
|
467
|
+
render_mode=render_mode,
|
|
468
|
+
)
|
|
469
|
+
parts.append({"part": "text", "render_mode": render_mode, **text_result})
|
|
470
|
+
for index, attachment in enumerate(native_attachments, start=1):
|
|
471
|
+
media_kind = str(attachment.get("qq_media_kind") or "file").strip()
|
|
472
|
+
media_result = self._send_media_attachment(
|
|
473
|
+
access_token=access_token,
|
|
474
|
+
chat_type=chat_type,
|
|
475
|
+
chat_id=chat_id,
|
|
476
|
+
attachment=attachment,
|
|
477
|
+
reply_to_message_id=reply_to,
|
|
478
|
+
)
|
|
479
|
+
parts.append(
|
|
480
|
+
{
|
|
481
|
+
"part": f"attachment_{index}",
|
|
482
|
+
"media_kind": media_kind,
|
|
483
|
+
"path": attachment.get("path"),
|
|
484
|
+
"url": attachment.get("url"),
|
|
485
|
+
**media_result,
|
|
486
|
+
}
|
|
487
|
+
)
|
|
488
|
+
succeeded = [item for item in parts if bool(item.get("ok", False))]
|
|
489
|
+
failed = [item for item in parts if not bool(item.get("ok", False))]
|
|
490
|
+
error_messages = [str(item.get("error") or "").strip() for item in failed if str(item.get("error") or "").strip()]
|
|
491
|
+
error_messages.extend(warnings)
|
|
492
|
+
last_success = succeeded[-1] if succeeded else {}
|
|
493
|
+
return {
|
|
494
|
+
"ok": bool(succeeded),
|
|
495
|
+
"queued": False,
|
|
496
|
+
"partial": bool(succeeded and (failed or warnings)),
|
|
497
|
+
"transport": "qq-http",
|
|
498
|
+
"status_code": last_success.get("status_code") or (failed[-1].get("status_code") if failed else None),
|
|
499
|
+
"message_id": last_success.get("message_id"),
|
|
500
|
+
"response": str(last_success.get("response") or "").strip()[:500] or None,
|
|
501
|
+
"parts": parts,
|
|
502
|
+
"warnings": warnings,
|
|
503
|
+
"error": "; ".join(error_messages) if error_messages else None,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
@staticmethod
|
|
507
|
+
def _requested_render_mode(payload: dict[str, Any]) -> str:
|
|
508
|
+
connector_hints = payload.get("connector_hints") if isinstance(payload.get("connector_hints"), dict) else {}
|
|
509
|
+
qq_hints = connector_hints.get("qq") if isinstance(connector_hints.get("qq"), dict) else {}
|
|
510
|
+
requested = str(qq_hints.get("render_mode") or "auto").strip().lower()
|
|
511
|
+
return requested if requested in {"auto", "plain", "markdown"} else "auto"
|
|
512
|
+
|
|
513
|
+
@staticmethod
|
|
514
|
+
def _effective_render_mode(requested: str, config: dict[str, Any]) -> tuple[str, str | None]:
|
|
515
|
+
markdown_enabled = bool(config.get("enable_markdown_send", False))
|
|
516
|
+
if requested == "markdown" and not markdown_enabled:
|
|
517
|
+
return "plain", "QQ markdown send was requested, but `enable_markdown_send` is disabled."
|
|
518
|
+
if requested == "markdown":
|
|
519
|
+
return "markdown", None
|
|
520
|
+
return "plain", None
|
|
521
|
+
|
|
522
|
+
@classmethod
|
|
523
|
+
def _partition_native_attachments(
|
|
524
|
+
cls,
|
|
525
|
+
attachments: Any,
|
|
526
|
+
config: dict[str, Any],
|
|
527
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
|
528
|
+
native_enabled = bool(config.get("enable_file_upload_experimental", False))
|
|
529
|
+
native_items: list[dict[str, Any]] = []
|
|
530
|
+
residual_items: list[dict[str, Any]] = []
|
|
531
|
+
issues: list[dict[str, Any]] = []
|
|
532
|
+
for index, raw_item in enumerate(attachments if isinstance(attachments, list) else [], start=1):
|
|
533
|
+
if not isinstance(raw_item, dict):
|
|
534
|
+
continue
|
|
535
|
+
item = dict(raw_item)
|
|
536
|
+
if str(item.get("path_error") or "").strip():
|
|
537
|
+
issues.append(
|
|
538
|
+
{
|
|
539
|
+
"attachment_index": index,
|
|
540
|
+
"error": f"attachment {index}: path resolution failed for {item.get('path')}",
|
|
541
|
+
}
|
|
542
|
+
)
|
|
543
|
+
continue
|
|
544
|
+
connector_delivery = item.get("connector_delivery") if isinstance(item.get("connector_delivery"), dict) else {}
|
|
545
|
+
qq_delivery = connector_delivery.get("qq") if isinstance(connector_delivery.get("qq"), dict) else {}
|
|
546
|
+
media_kind = str(qq_delivery.get("media_kind") or "").strip().lower()
|
|
547
|
+
if media_kind not in {"image", "file"}:
|
|
548
|
+
residual_items.append(item)
|
|
549
|
+
continue
|
|
550
|
+
if not native_enabled:
|
|
551
|
+
issues.append(
|
|
552
|
+
{
|
|
553
|
+
"attachment_index": index,
|
|
554
|
+
"error": (
|
|
555
|
+
f"attachment {index}: QQ native media send is disabled by "
|
|
556
|
+
"`enable_file_upload_experimental`."
|
|
557
|
+
),
|
|
558
|
+
}
|
|
559
|
+
)
|
|
560
|
+
continue
|
|
561
|
+
source_path = str(item.get("path") or "").strip()
|
|
562
|
+
source_url = str(item.get("url") or "").strip()
|
|
563
|
+
if not source_path and not source_url:
|
|
564
|
+
issues.append(
|
|
565
|
+
{
|
|
566
|
+
"attachment_index": index,
|
|
567
|
+
"error": f"attachment {index}: QQ {media_kind} send requires `path` or `url`.",
|
|
568
|
+
}
|
|
569
|
+
)
|
|
570
|
+
continue
|
|
571
|
+
item["qq_media_kind"] = media_kind
|
|
572
|
+
native_items.append(item)
|
|
573
|
+
return native_items, residual_items, issues
|
|
574
|
+
|
|
575
|
+
@staticmethod
|
|
576
|
+
def _message_endpoint(chat_type: str, chat_id: str) -> str:
|
|
577
|
+
return (
|
|
441
578
|
f"https://api.sgroup.qq.com/v2/users/{chat_id}/messages"
|
|
442
579
|
if chat_type == "direct"
|
|
443
580
|
else f"https://api.sgroup.qq.com/v2/groups/{chat_id}/messages"
|
|
444
581
|
)
|
|
445
|
-
|
|
582
|
+
|
|
583
|
+
@staticmethod
|
|
584
|
+
def _files_endpoint(chat_type: str, chat_id: str) -> str:
|
|
585
|
+
return (
|
|
586
|
+
f"https://api.sgroup.qq.com/v2/users/{chat_id}/files"
|
|
587
|
+
if chat_type == "direct"
|
|
588
|
+
else f"https://api.sgroup.qq.com/v2/groups/{chat_id}/files"
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
@staticmethod
|
|
592
|
+
def _message_seq(reply_to_message_id: str | None) -> int | None:
|
|
593
|
+
if not reply_to_message_id:
|
|
594
|
+
return None
|
|
595
|
+
return max(int(time.time() * 1000) % 65536, 1)
|
|
596
|
+
|
|
597
|
+
def _send_text_message(
|
|
598
|
+
self,
|
|
599
|
+
*,
|
|
600
|
+
access_token: str,
|
|
601
|
+
chat_type: str,
|
|
602
|
+
chat_id: str,
|
|
603
|
+
text: str,
|
|
604
|
+
reply_to_message_id: str | None,
|
|
605
|
+
render_mode: str,
|
|
606
|
+
) -> dict[str, Any]:
|
|
607
|
+
if not text.strip():
|
|
608
|
+
return {"ok": False, "error": "QQ text message content is empty."}
|
|
609
|
+
body: dict[str, Any]
|
|
610
|
+
if render_mode == "markdown":
|
|
611
|
+
body = {"markdown": {"content": text}, "msg_type": 2}
|
|
612
|
+
else:
|
|
613
|
+
body = {"content": text, "msg_type": 0}
|
|
614
|
+
msg_seq = self._message_seq(reply_to_message_id)
|
|
615
|
+
if msg_seq is not None:
|
|
616
|
+
body["msg_seq"] = msg_seq
|
|
617
|
+
if reply_to_message_id:
|
|
618
|
+
body["msg_id"] = reply_to_message_id
|
|
619
|
+
return self._post_json(
|
|
620
|
+
endpoint=self._message_endpoint(chat_type, chat_id),
|
|
621
|
+
access_token=access_token,
|
|
622
|
+
body=body,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
def _send_media_attachment(
|
|
626
|
+
self,
|
|
627
|
+
*,
|
|
628
|
+
access_token: str,
|
|
629
|
+
chat_type: str,
|
|
630
|
+
chat_id: str,
|
|
631
|
+
attachment: dict[str, Any],
|
|
632
|
+
reply_to_message_id: str | None,
|
|
633
|
+
) -> dict[str, Any]:
|
|
634
|
+
media_kind = str(attachment.get("qq_media_kind") or "file").strip().lower()
|
|
635
|
+
upload_payload: dict[str, Any] = {
|
|
636
|
+
"file_type": self._IMAGE_FILE_TYPE if media_kind == "image" else self._FILE_FILE_TYPE,
|
|
637
|
+
"srv_send_msg": False,
|
|
638
|
+
}
|
|
639
|
+
url_value = str(attachment.get("url") or "").strip()
|
|
640
|
+
path_value = str(attachment.get("path") or "").strip()
|
|
641
|
+
try:
|
|
642
|
+
if url_value:
|
|
643
|
+
upload_payload["url"] = url_value
|
|
644
|
+
elif path_value:
|
|
645
|
+
payload, file_name = self._file_data_payload(Path(path_value), media_kind=media_kind)
|
|
646
|
+
upload_payload["file_data"] = payload
|
|
647
|
+
if media_kind == "file" and file_name:
|
|
648
|
+
upload_payload["file_name"] = file_name
|
|
649
|
+
else:
|
|
650
|
+
return {"ok": False, "error": "QQ native media send requires `path` or `url`."}
|
|
651
|
+
except Exception as exc:
|
|
652
|
+
return {"ok": False, "error": str(exc)}
|
|
653
|
+
upload_result = self._post_json(
|
|
654
|
+
endpoint=self._files_endpoint(chat_type, chat_id),
|
|
655
|
+
access_token=access_token,
|
|
656
|
+
body=upload_payload,
|
|
657
|
+
)
|
|
658
|
+
if not upload_result.get("ok", False):
|
|
659
|
+
upload_result["error"] = str(upload_result.get("error") or "QQ media upload failed.").strip()
|
|
660
|
+
return upload_result
|
|
661
|
+
file_info = str(upload_result.get("payload", {}).get("file_info") or "").strip()
|
|
662
|
+
if not file_info:
|
|
663
|
+
return {
|
|
664
|
+
"ok": False,
|
|
665
|
+
"error": "QQ media upload succeeded but returned no `file_info`.",
|
|
666
|
+
"status_code": upload_result.get("status_code"),
|
|
667
|
+
"response": upload_result.get("response"),
|
|
668
|
+
}
|
|
669
|
+
message_body: dict[str, Any] = {
|
|
670
|
+
"msg_type": 7,
|
|
671
|
+
"media": {"file_info": file_info},
|
|
672
|
+
}
|
|
673
|
+
msg_seq = self._message_seq(reply_to_message_id)
|
|
674
|
+
if msg_seq is not None:
|
|
675
|
+
message_body["msg_seq"] = msg_seq
|
|
676
|
+
if reply_to_message_id:
|
|
677
|
+
message_body["msg_id"] = reply_to_message_id
|
|
678
|
+
send_result = self._post_json(
|
|
679
|
+
endpoint=self._message_endpoint(chat_type, chat_id),
|
|
680
|
+
access_token=access_token,
|
|
681
|
+
body=message_body,
|
|
682
|
+
)
|
|
683
|
+
if not send_result.get("ok", False):
|
|
684
|
+
send_result["error"] = str(send_result.get("error") or "QQ media message send failed.").strip()
|
|
685
|
+
return send_result
|
|
686
|
+
|
|
687
|
+
@staticmethod
|
|
688
|
+
def _file_data_payload(path: Path, *, media_kind: str) -> tuple[str, str]:
|
|
689
|
+
if not path.exists():
|
|
690
|
+
raise FileNotFoundError(f"QQ native {media_kind} attachment path does not exist: {path}")
|
|
691
|
+
payload = base64.b64encode(path.read_bytes()).decode("utf-8")
|
|
692
|
+
return payload, path.name
|
|
693
|
+
|
|
694
|
+
def _post_json(self, *, endpoint: str, access_token: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
695
|
+
request = Request(endpoint, data=json.dumps(body, ensure_ascii=False).encode("utf-8"), method="POST")
|
|
446
696
|
request.add_header("Content-Type", "application/json; charset=utf-8")
|
|
447
697
|
request.add_header("Authorization", f"QQBot {access_token}")
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
698
|
+
try:
|
|
699
|
+
with urlopen(request, timeout=8) as response: # noqa: S310
|
|
700
|
+
response_text = response.read().decode("utf-8", errors="replace")
|
|
701
|
+
payload = json.loads(response_text) if response_text else {}
|
|
702
|
+
return {
|
|
703
|
+
"ok": 200 <= response.status < 300,
|
|
704
|
+
"status_code": response.status,
|
|
705
|
+
"response": response_text[:500],
|
|
706
|
+
"payload": payload if isinstance(payload, dict) else {},
|
|
707
|
+
"message_id": str((payload or {}).get("id") or "").strip() or None,
|
|
708
|
+
}
|
|
709
|
+
except HTTPError as exc:
|
|
710
|
+
response_text = exc.read().decode("utf-8", errors="replace")
|
|
711
|
+
try:
|
|
712
|
+
payload = json.loads(response_text) if response_text else {}
|
|
713
|
+
except json.JSONDecodeError:
|
|
714
|
+
payload = {}
|
|
715
|
+
return {
|
|
716
|
+
"ok": False,
|
|
717
|
+
"status_code": exc.code,
|
|
718
|
+
"response": response_text[:500],
|
|
719
|
+
"payload": payload if isinstance(payload, dict) else {},
|
|
720
|
+
"error": str((payload or {}).get("message") or response_text or exc.reason).strip() or "QQ HTTP error",
|
|
721
|
+
}
|
|
722
|
+
except Exception as exc: # pragma: no cover - defensive network transport guard
|
|
723
|
+
return {
|
|
724
|
+
"ok": False,
|
|
725
|
+
"status_code": None,
|
|
726
|
+
"response": None,
|
|
727
|
+
"payload": {},
|
|
728
|
+
"error": str(exc),
|
|
729
|
+
}
|
|
451
730
|
|
|
452
731
|
@classmethod
|
|
453
732
|
def _access_token(cls, app_id: str, app_secret: str) -> str:
|
|
@@ -352,12 +352,15 @@ class QQRelayChannel(BaseChannel):
|
|
|
352
352
|
fragments.append(f"Reason: {reason}")
|
|
353
353
|
text = "\n".join(fragments)
|
|
354
354
|
attachments = self._normalize_attachments(payload.get("attachments"))
|
|
355
|
+
conversation_id = str(payload.get("conversation_id") or "").strip()
|
|
355
356
|
return {
|
|
356
|
-
"conversation_id":
|
|
357
|
-
"reply_to_message_id": payload.get("reply_to_message_id"),
|
|
357
|
+
"conversation_id": conversation_id,
|
|
358
|
+
"reply_to_message_id": payload.get("reply_to_message_id") or self._reply_to_message_id_for(conversation_id),
|
|
358
359
|
"kind": kind,
|
|
359
360
|
"text": text,
|
|
360
361
|
"attachments": attachments,
|
|
362
|
+
"surface_actions": [dict(item) for item in (payload.get("surface_actions") or []) if isinstance(item, dict)],
|
|
363
|
+
"connector_hints": dict(payload.get("connector_hints")) if isinstance(payload.get("connector_hints"), dict) else {},
|
|
361
364
|
"quest_id": payload.get("quest_id"),
|
|
362
365
|
"quest_root": payload.get("quest_root"),
|
|
363
366
|
"importance": payload.get("importance"),
|
|
@@ -548,6 +551,20 @@ class QQRelayChannel(BaseChannel):
|
|
|
548
551
|
events.sort(key=lambda item: (str(item.get("created_at") or ""), str(item.get("conversation_id") or "")), reverse=True)
|
|
549
552
|
return events[: self.recent_event_limit]
|
|
550
553
|
|
|
554
|
+
def _reply_to_message_id_for(self, conversation_id: str) -> str | None:
|
|
555
|
+
normalized = str(conversation_id or "").strip()
|
|
556
|
+
if not normalized:
|
|
557
|
+
return None
|
|
558
|
+
identity = conversation_identity_key(normalized)
|
|
559
|
+
state = self._read_state()
|
|
560
|
+
for item in self._recent_conversations(state):
|
|
561
|
+
if conversation_identity_key(str(item.get("conversation_id") or "").strip()) != identity:
|
|
562
|
+
continue
|
|
563
|
+
message_id = str(item.get("message_id") or "").strip()
|
|
564
|
+
if message_id:
|
|
565
|
+
return message_id
|
|
566
|
+
return None
|
|
567
|
+
|
|
551
568
|
def _build_recent_event(self, event_type: str, record: dict[str, Any]) -> dict[str, Any] | None:
|
|
552
569
|
if not isinstance(record, dict):
|
|
553
570
|
return None
|
|
@@ -337,6 +337,7 @@ class GenericRelayChannel(BaseChannel):
|
|
|
337
337
|
"kind": kind,
|
|
338
338
|
"text": text,
|
|
339
339
|
"attachments": attachments,
|
|
340
|
+
"surface_actions": [dict(item) for item in (payload.get("surface_actions") or []) if isinstance(item, dict)],
|
|
340
341
|
"quest_id": payload.get("quest_id"),
|
|
341
342
|
"quest_root": payload.get("quest_root"),
|
|
342
343
|
"importance": payload.get("importance"),
|