codebee 0.1.101 → 0.1.103
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/CHANGELOG.md +14 -0
- package/LICENSE +21 -21
- package/README.md +3 -6
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/core/acceptance.py +100 -100
- package/app/core/acp_client.py +457 -426
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backend_profiles.py +188 -188
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -102
- package/app/core/board.py +21 -17
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +13 -4
- package/app/core/capability.py +144 -144
- package/app/core/catalog.py +28 -6
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +117 -31
- package/app/core/cleanup.py +303 -303
- package/app/core/command_auth.py +302 -302
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +1 -1
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +91 -82
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -33
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/knowledge_pipeline.py +86 -86
- package/app/core/manager.py +2385 -2385
- package/app/core/mcp_client.py +7 -2
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -181
- package/app/core/openapi.py +217 -209
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +6 -3
- package/app/core/policy.py +9 -0
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/project_memory.py +206 -206
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +659 -562
- package/app/core/publish/chaprows.py +138 -138
- package/app/core/publish/fanqie.py +16 -10
- package/app/core/publish/flow.py +29 -1
- package/app/core/publish/flows-fanqie-calibrated.json +442 -442
- package/app/core/publish/manager.py +1185 -1177
- package/app/core/publish/qimao.py +193 -193
- package/app/core/publish/volumes.py +108 -108
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +201 -201
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/sandbox_runtime.py +95 -0
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +215 -215
- package/app/core/settings_schema.py +282 -282
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +13 -3
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tool_outputs.py +107 -107
- package/app/core/tracing.py +207 -207
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -89
- package/app/core/wxdigest.py +865 -865
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +1 -0
- package/app/mcp_server.py +244 -244
- package/app/output/_createbook_body.json +1 -0
- package/app/output/_probe_fq_click.py +50 -0
- package/app/output/_probe_fq_form.py +42 -0
- package/app/output/_probe_fq_modal.py +55 -0
- package/app/output/_probe_fq_points.py +71 -0
- package/app/output/_probe_fq_submit.py +34 -0
- package/app/output/_probe_fq_watch.py +62 -0
- package/app/output/workbench-clean-preview.jpg +0 -0
- package/app/output/workbench-reference-render.png +0 -0
- package/app/output/workbench-shell.png +0 -0
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +165 -29
- package/app/ui/artdiff.js +74 -74
- package/app/ui/hive3d.js +491 -223
- package/app/ui/i18n.js +18 -1
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +24 -4
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +548 -18
- package/bin/tutti.js +147 -147
- package/package.json +1 -1
- package/tools/wx_reader.py +261 -261
package/app/core/mcp_client.py
CHANGED
|
@@ -98,10 +98,15 @@ def _sandbox_launch(server, sandbox, workdir):
|
|
|
98
98
|
from . import policy
|
|
99
99
|
if not sandbox or not workdir:
|
|
100
100
|
raise RuntimeError("MCP 调用缺少任务沙箱策略或工作目录,拒绝启动")
|
|
101
|
-
if os.name != "posix" or sys.platform == "darwin" or not shutil.which("bwrap"):
|
|
102
|
-
raise RuntimeError("MCP 服务尚无可用的 OS 隔离后端,拒绝启动")
|
|
103
101
|
root = Path(workdir).expanduser().resolve(strict=True)
|
|
104
102
|
normalized = policy.normalize_sandbox(sandbox, root)
|
|
103
|
+
if normalized.get("backend") == "docker":
|
|
104
|
+
# MCP command/args may include host-specific executables and absolute
|
|
105
|
+
# script paths. Until those are explicitly mapped into an image and
|
|
106
|
+
# verified mounts, refuse instead of launching a misleading container.
|
|
107
|
+
raise RuntimeError("MCP Docker 启动映射尚未配置;拒绝降级到宿主机")
|
|
108
|
+
if os.name != "posix" or sys.platform == "darwin" or not shutil.which("bwrap"):
|
|
109
|
+
raise RuntimeError("MCP 服务尚无可用的 OS 隔离后端,拒绝启动")
|
|
105
110
|
roots = [Path(value).resolve(strict=True)
|
|
106
111
|
for value in normalized.get("allowed_roots") or []]
|
|
107
112
|
if not roots:
|
package/app/core/mocks.py
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""内置演示/测试智能体(mock):不消耗任何配额即可跑通全流程。"""
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import hashlib
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def _seed(agent_id):
|
|
9
|
-
return int(hashlib.sha256(agent_id.encode("utf-8")).hexdigest()[:6], 16)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def draft_manuscript(task, round_no):
|
|
13
|
-
"""生成一份示例稿件(mock 起草/修订直接落盘的内容)。"""
|
|
14
|
-
title = task.get("title") or "未命名章节"
|
|
15
|
-
goal = task.get("goal") or ""
|
|
16
|
-
return (
|
|
17
|
-
"# {title}\n\n"
|
|
18
|
-
"这是第 {r} 轮 mock 草稿(演示用,不消耗模型配额)。\n\n"
|
|
19
|
-
"## 任务目标\n{goal}\n\n"
|
|
20
|
-
"## 正文\n"
|
|
21
|
-
"夜色沿着山脊线缓慢退去,主角终于看清了石门上的刻痕——那不是文字,"
|
|
22
|
-
"而是一段被反复涂抹又反复显影的名字。他伸手触碰的瞬间,耳边响起的"
|
|
23
|
-
"却是自己昨夜说过的话。{extra}\n\n"
|
|
24
|
-
"(本轮修订关注:{focus})\n"
|
|
25
|
-
).format(
|
|
26
|
-
title=title, r=round_no, goal=goal,
|
|
27
|
-
extra="这一段在第 2 轮修订中补充了人物动机的伏笔。" if round_no >= 2 else "",
|
|
28
|
-
focus="节奏与悬念铺排" if round_no == 1 else "人物弧光与首尾呼应",
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def implement_note(task):
|
|
33
|
-
return "(mock 实现)已在目标目录写入 mock-impl.txt 作为变更占位。"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def critique(agent_id, round_no, dims, threshold):
|
|
37
|
-
"""确定性评分:第 1 轮略低于阈值,第 2 轮达标,用于验证发布门禁逻辑。"""
|
|
38
|
-
s = _seed(agent_id)
|
|
39
|
-
scores = {}
|
|
40
|
-
for i, d in enumerate(dims):
|
|
41
|
-
jitter = ((s >> i) % 5) * 0.1
|
|
42
|
-
scores[d] = round(min(9.5, 4.8 + 1.2 * round_no + jitter), 1)
|
|
43
|
-
passed = all(v >= threshold for v in scores.values())
|
|
44
|
-
issues = []
|
|
45
|
-
if round_no == 1:
|
|
46
|
-
issues = [
|
|
47
|
-
{"dim": dims[0], "severity": "major", "note": "开篇信息密度偏低,钩子出现太晚(mock 意见)"},
|
|
48
|
-
{"dim": dims[-1], "severity": "minor", "note": "段落长短交替不够,读感偏平(mock 意见)"},
|
|
49
|
-
]
|
|
50
|
-
return {
|
|
51
|
-
"scores": scores,
|
|
52
|
-
"issues": issues,
|
|
53
|
-
"summary": "mock 总评:第 %d 轮稿件%s发布阈值" % (round_no, "已达到" if passed else "尚未达到"),
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def review(task, verify_passed):
|
|
58
|
-
return {
|
|
59
|
-
"pass": bool(verify_passed),
|
|
60
|
-
"scores": {"正确性": 8.0 if verify_passed else 5.0, "可维护性": 8.0, "安全": 8.5},
|
|
61
|
-
"issues": [] if verify_passed else [
|
|
62
|
-
{"severity": "major", "title": "验证命令未通过", "detail": "请检查测试输出(mock 意见)"}],
|
|
63
|
-
"summary": "mock 代码评审:%s" % ("通过" if verify_passed else "未通过"),
|
|
64
|
-
}
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""内置演示/测试智能体(mock):不消耗任何配额即可跑通全流程。"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _seed(agent_id):
|
|
9
|
+
return int(hashlib.sha256(agent_id.encode("utf-8")).hexdigest()[:6], 16)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def draft_manuscript(task, round_no):
|
|
13
|
+
"""生成一份示例稿件(mock 起草/修订直接落盘的内容)。"""
|
|
14
|
+
title = task.get("title") or "未命名章节"
|
|
15
|
+
goal = task.get("goal") or ""
|
|
16
|
+
return (
|
|
17
|
+
"# {title}\n\n"
|
|
18
|
+
"这是第 {r} 轮 mock 草稿(演示用,不消耗模型配额)。\n\n"
|
|
19
|
+
"## 任务目标\n{goal}\n\n"
|
|
20
|
+
"## 正文\n"
|
|
21
|
+
"夜色沿着山脊线缓慢退去,主角终于看清了石门上的刻痕——那不是文字,"
|
|
22
|
+
"而是一段被反复涂抹又反复显影的名字。他伸手触碰的瞬间,耳边响起的"
|
|
23
|
+
"却是自己昨夜说过的话。{extra}\n\n"
|
|
24
|
+
"(本轮修订关注:{focus})\n"
|
|
25
|
+
).format(
|
|
26
|
+
title=title, r=round_no, goal=goal,
|
|
27
|
+
extra="这一段在第 2 轮修订中补充了人物动机的伏笔。" if round_no >= 2 else "",
|
|
28
|
+
focus="节奏与悬念铺排" if round_no == 1 else "人物弧光与首尾呼应",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def implement_note(task):
|
|
33
|
+
return "(mock 实现)已在目标目录写入 mock-impl.txt 作为变更占位。"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def critique(agent_id, round_no, dims, threshold):
|
|
37
|
+
"""确定性评分:第 1 轮略低于阈值,第 2 轮达标,用于验证发布门禁逻辑。"""
|
|
38
|
+
s = _seed(agent_id)
|
|
39
|
+
scores = {}
|
|
40
|
+
for i, d in enumerate(dims):
|
|
41
|
+
jitter = ((s >> i) % 5) * 0.1
|
|
42
|
+
scores[d] = round(min(9.5, 4.8 + 1.2 * round_no + jitter), 1)
|
|
43
|
+
passed = all(v >= threshold for v in scores.values())
|
|
44
|
+
issues = []
|
|
45
|
+
if round_no == 1:
|
|
46
|
+
issues = [
|
|
47
|
+
{"dim": dims[0], "severity": "major", "note": "开篇信息密度偏低,钩子出现太晚(mock 意见)"},
|
|
48
|
+
{"dim": dims[-1], "severity": "minor", "note": "段落长短交替不够,读感偏平(mock 意见)"},
|
|
49
|
+
]
|
|
50
|
+
return {
|
|
51
|
+
"scores": scores,
|
|
52
|
+
"issues": issues,
|
|
53
|
+
"summary": "mock 总评:第 %d 轮稿件%s发布阈值" % (round_no, "已达到" if passed else "尚未达到"),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def review(task, verify_passed):
|
|
58
|
+
return {
|
|
59
|
+
"pass": bool(verify_passed),
|
|
60
|
+
"scores": {"正确性": 8.0 if verify_passed else 5.0, "可维护性": 8.0, "安全": 8.5},
|
|
61
|
+
"issues": [] if verify_passed else [
|
|
62
|
+
{"severity": "major", "title": "验证命令未通过", "detail": "请检查测试输出(mock 意见)"}],
|
|
63
|
+
"summary": "mock 代码评审:%s" % ("通过" if verify_passed else "未通过"),
|
|
64
|
+
}
|
package/app/core/notify.py
CHANGED
|
@@ -1,201 +1,201 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""运行结果推送(借鉴 agency-orchestrator 的 --notify):任务跑完把结果摘要
|
|
3
|
-
推到钉钉/飞书/企业微信群机器人,配合定时自动化就是「AI 团队每天定点交活」。
|
|
4
|
-
|
|
5
|
-
两类通道:
|
|
6
|
-
- 群机器人 webhook 一个地址全包——按域名自动适配三种机器人格式;也接受
|
|
7
|
-
任意 https 地址(按钉钉 text 形状发,自建 n8n 等自选)。
|
|
8
|
-
- 个人推送通道(Bark / ntfy / Server酱 / Telegram Bot):推到私人手机,
|
|
9
|
-
无人值守场景「半夜失败/待裁决」的刚需;群机器人是给群里看的,这是给你看的。
|
|
10
|
-
推送在后台线程、失败只记日志——通知永远不影响任务本身。
|
|
11
|
-
"""
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import logging
|
|
15
|
-
|
|
16
|
-
log = logging.getLogger(__name__)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _payload_for(webhook, text):
|
|
20
|
-
"""按 webhook 域名适配群机器人消息体。返回 None = 无法识别的地址。"""
|
|
21
|
-
from urllib.parse import urlparse
|
|
22
|
-
host = (urlparse(webhook).hostname or "").lower()
|
|
23
|
-
if "dingtalk" in host:
|
|
24
|
-
return {"msgtype": "text", "text": {"content": text}}
|
|
25
|
-
if "feishu" in host or "larksuite" in host:
|
|
26
|
-
return {"msg_type": "text", "content": {"text": text}}
|
|
27
|
-
if "weixin" in host or "wechat" in host or "work.weixin" in host:
|
|
28
|
-
return {"msgtype": "text", "text": {"content": text}}
|
|
29
|
-
if host:
|
|
30
|
-
return {"msgtype": "text", "text": {"content": text}} # 未知域名按钉钉形状
|
|
31
|
-
return None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _webhook():
|
|
35
|
-
try:
|
|
36
|
-
from . import settings
|
|
37
|
-
return str(settings.load().get("notify_webhook") or "").strip()
|
|
38
|
-
except Exception:
|
|
39
|
-
return ""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _send(url, json_body=None, form=None):
|
|
43
|
-
"""curl 子进程发送(Python 不经手响应体);网络失败只记日志。返回 bool。
|
|
44
|
-
|
|
45
|
-
json_body=JSON 体;form=表单体(值需 UTF-8 百分号编码,urlencode 负责)。
|
|
46
|
-
中文一律走请求体(Server酱表单 / ntfy JSON publish),不走 HTTP 头——
|
|
47
|
-
头里的非 ASCII 会被网关拒收(历史坑)。
|
|
48
|
-
"""
|
|
49
|
-
from . import runner
|
|
50
|
-
import json as _json
|
|
51
|
-
argv = ["curl", "-sS", "--max-time", "20"]
|
|
52
|
-
if json_body is not None:
|
|
53
|
-
argv += ["-H", "Content-Type: application/json",
|
|
54
|
-
"-d", _json.dumps(json_body, ensure_ascii=False)]
|
|
55
|
-
elif form is not None:
|
|
56
|
-
from urllib.parse import urlencode
|
|
57
|
-
argv += ["-H", "Content-Type: application/x-www-form-urlencoded",
|
|
58
|
-
"-d", urlencode(form)]
|
|
59
|
-
argv.append(url)
|
|
60
|
-
r = runner.run_process(argv=argv, timeout=30)
|
|
61
|
-
return bool(r["ok"])
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _post(hook, text):
|
|
65
|
-
return _send(hook, json_body=_payload_for(hook, text))
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _personal_channels():
|
|
69
|
-
"""读设置里的个人推送配置,返回 [(通道名, 发送函数(title, body)), ...]。
|
|
70
|
-
|
|
71
|
-
只收录配置完整的通道;键名与 settings.DEFAULTS 对齐。
|
|
72
|
-
"""
|
|
73
|
-
from . import settings
|
|
74
|
-
s = settings.load()
|
|
75
|
-
chs = []
|
|
76
|
-
|
|
77
|
-
bark_key = str(s.get("notify_bark_key") or "").strip()
|
|
78
|
-
if bark_key:
|
|
79
|
-
server = (str(s.get("notify_bark_server") or "").strip().rstrip("/")
|
|
80
|
-
or "https://api.day.app")
|
|
81
|
-
chs.append(("bark", lambda t, b: _send(
|
|
82
|
-
server + "/push",
|
|
83
|
-
json_body={"device_key": bark_key, "title": t, "body": b})))
|
|
84
|
-
|
|
85
|
-
ntfy = str(s.get("notify_ntfy_topic") or "").strip()
|
|
86
|
-
if ntfy:
|
|
87
|
-
from urllib.parse import urlparse
|
|
88
|
-
u = urlparse(ntfy if "://" in ntfy else "https://ntfy.sh/" + ntfy.lstrip("/"))
|
|
89
|
-
topic = u.path.strip("/")
|
|
90
|
-
if topic:
|
|
91
|
-
server = "%s://%s" % (u.scheme, u.netloc)
|
|
92
|
-
chs.append(("ntfy", lambda t, b: _send(
|
|
93
|
-
server + "/",
|
|
94
|
-
json_body={"topic": topic, "title": t, "message": b})))
|
|
95
|
-
|
|
96
|
-
sc_key = str(s.get("notify_serverchan_key") or "").strip()
|
|
97
|
-
if sc_key:
|
|
98
|
-
chs.append(("serverchan", lambda t, b: _send(
|
|
99
|
-
"https://sctapi.ftqq.com/%s.send" % sc_key,
|
|
100
|
-
form={"title": t, "desp": b})))
|
|
101
|
-
|
|
102
|
-
tg_token = str(s.get("notify_telegram_token") or "").strip()
|
|
103
|
-
tg_chat = str(s.get("notify_telegram_chat_id") or "").strip()
|
|
104
|
-
if tg_token and tg_chat:
|
|
105
|
-
chs.append(("telegram", lambda t, b: _send(
|
|
106
|
-
"https://api.telegram.org/bot%s/sendMessage" % tg_token,
|
|
107
|
-
json_body={"chat_id": tg_chat, "text": (t + "\n" + b).strip()})))
|
|
108
|
-
return chs
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _split_title(text):
|
|
112
|
-
"""首行作标题(≤40 字),其余作正文;单行文本标题正文同文。"""
|
|
113
|
-
lines = [ln for ln in str(text).splitlines() if ln.strip()]
|
|
114
|
-
title = (lines[0].strip() if lines else "CodeBee")[:40]
|
|
115
|
-
body = "\n".join(ln.strip() for ln in lines[1:]).strip() or title
|
|
116
|
-
return title, body
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def push_text_ex(text):
|
|
120
|
-
"""推一条文本到全部已配置通道,返回 {通道名: 是否成功}。
|
|
121
|
-
|
|
122
|
-
未配置任何通道返回 {};单通道失败不影响其他通道。
|
|
123
|
-
"""
|
|
124
|
-
results = {}
|
|
125
|
-
hook = _webhook()
|
|
126
|
-
if hook:
|
|
127
|
-
if not hook.startswith("https://"):
|
|
128
|
-
log.warning("notify webhook 必须是 https")
|
|
129
|
-
results["webhook"] = False
|
|
130
|
-
else:
|
|
131
|
-
try:
|
|
132
|
-
results["webhook"] = _post(hook, text)
|
|
133
|
-
except Exception as e:
|
|
134
|
-
log.warning("notify push failed: %s", e)
|
|
135
|
-
results["webhook"] = False
|
|
136
|
-
try:
|
|
137
|
-
title, body = _split_title(text)
|
|
138
|
-
except Exception:
|
|
139
|
-
title, body = "CodeBee", str(text)
|
|
140
|
-
for name, fn in _personal_channels():
|
|
141
|
-
try:
|
|
142
|
-
results[name] = bool(fn(title, body))
|
|
143
|
-
except Exception as e:
|
|
144
|
-
log.warning("notify %s push failed: %s", name, e)
|
|
145
|
-
results[name] = False
|
|
146
|
-
return results
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def push_text(text):
|
|
150
|
-
"""推一条文本;任一通道成功即 True;全未配置/全失败返回 False,不抛错。"""
|
|
151
|
-
try:
|
|
152
|
-
return any(push_text_ex(text).values())
|
|
153
|
-
except Exception as e:
|
|
154
|
-
log.warning("notify push failed: %s", e)
|
|
155
|
-
return False
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def push_run_async(run_id):
|
|
159
|
-
"""任务收尾后异步推送结果摘要(jobs 层调用;绝不阻塞/影响任务)。"""
|
|
160
|
-
threading.Thread(target=push_run, daemon=True,
|
|
161
|
-
name="notify-%s" % run_id, args=(run_id,)).start()
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def push_run(run_id):
|
|
165
|
-
"""组装 run 结果摘要并推送(群 webhook 或任一个人通道配置了才推)。
|
|
166
|
-
|
|
167
|
-
摘要末尾附分享页路径(notify_base_url 设置非空时拼完整链接,否则只给
|
|
168
|
-
run_id 供在本机 CodeBee 界面查找)。"""
|
|
169
|
-
from . import store
|
|
170
|
-
run = store.get_run(run_id) or {}
|
|
171
|
-
if not run:
|
|
172
|
-
return False
|
|
173
|
-
status = str(run.get("status") or "")
|
|
174
|
-
mark = {"done": "✅", "failed": "❌", "cancelled": "⚪"}.get(status, "🔔")
|
|
175
|
-
lines = ["%s CodeBee 任务%s" % (mark, {"done": "完成", "failed": "失败",
|
|
176
|
-
"cancelled": "已取消"}.get(status, status))]
|
|
177
|
-
lines.append("任务:%s" % (run.get("title") or run_id))
|
|
178
|
-
if run.get("error"):
|
|
179
|
-
lines.append("错误:%s" % str(run["error"])[:200])
|
|
180
|
-
v = run.get("verdict") or {}
|
|
181
|
-
if v.get("overall") is not None:
|
|
182
|
-
lines.append("综合评分 %.1f(%s)" % (
|
|
183
|
-
float(v["overall"]), "达标" if v.get("publishable") else "未达标"))
|
|
184
|
-
try:
|
|
185
|
-
from .settings import load as _sload
|
|
186
|
-
base = str(_sload().get("notify_base_url") or "").strip().rstrip("/")
|
|
187
|
-
if base:
|
|
188
|
-
lines.append("📄 详情:%s/api/runs/%s/share" % (base, run_id))
|
|
189
|
-
except Exception:
|
|
190
|
-
pass
|
|
191
|
-
# 分支待裁决:任务跑完有改动等人拍板时点名提醒(手机端看到即可去详情处理)
|
|
192
|
-
try:
|
|
193
|
-
task = store.get_task(run.get("task_id")) if run.get("task_id") else None
|
|
194
|
-
if task and task.get("git_state") == "isolated":
|
|
195
|
-
lines.append("⚠ 任务分支待裁决(合并/丢弃)")
|
|
196
|
-
except Exception:
|
|
197
|
-
pass
|
|
198
|
-
return push_text("\n".join(lines))
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
import threading # noqa: E402 (push_run_async 依赖;置底避免顶部循环导入)
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""运行结果推送(借鉴 agency-orchestrator 的 --notify):任务跑完把结果摘要
|
|
3
|
+
推到钉钉/飞书/企业微信群机器人,配合定时自动化就是「AI 团队每天定点交活」。
|
|
4
|
+
|
|
5
|
+
两类通道:
|
|
6
|
+
- 群机器人 webhook 一个地址全包——按域名自动适配三种机器人格式;也接受
|
|
7
|
+
任意 https 地址(按钉钉 text 形状发,自建 n8n 等自选)。
|
|
8
|
+
- 个人推送通道(Bark / ntfy / Server酱 / Telegram Bot):推到私人手机,
|
|
9
|
+
无人值守场景「半夜失败/待裁决」的刚需;群机器人是给群里看的,这是给你看的。
|
|
10
|
+
推送在后台线程、失败只记日志——通知永远不影响任务本身。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _payload_for(webhook, text):
|
|
20
|
+
"""按 webhook 域名适配群机器人消息体。返回 None = 无法识别的地址。"""
|
|
21
|
+
from urllib.parse import urlparse
|
|
22
|
+
host = (urlparse(webhook).hostname or "").lower()
|
|
23
|
+
if "dingtalk" in host:
|
|
24
|
+
return {"msgtype": "text", "text": {"content": text}}
|
|
25
|
+
if "feishu" in host or "larksuite" in host:
|
|
26
|
+
return {"msg_type": "text", "content": {"text": text}}
|
|
27
|
+
if "weixin" in host or "wechat" in host or "work.weixin" in host:
|
|
28
|
+
return {"msgtype": "text", "text": {"content": text}}
|
|
29
|
+
if host:
|
|
30
|
+
return {"msgtype": "text", "text": {"content": text}} # 未知域名按钉钉形状
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _webhook():
|
|
35
|
+
try:
|
|
36
|
+
from . import settings
|
|
37
|
+
return str(settings.load().get("notify_webhook") or "").strip()
|
|
38
|
+
except Exception:
|
|
39
|
+
return ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _send(url, json_body=None, form=None):
|
|
43
|
+
"""curl 子进程发送(Python 不经手响应体);网络失败只记日志。返回 bool。
|
|
44
|
+
|
|
45
|
+
json_body=JSON 体;form=表单体(值需 UTF-8 百分号编码,urlencode 负责)。
|
|
46
|
+
中文一律走请求体(Server酱表单 / ntfy JSON publish),不走 HTTP 头——
|
|
47
|
+
头里的非 ASCII 会被网关拒收(历史坑)。
|
|
48
|
+
"""
|
|
49
|
+
from . import runner
|
|
50
|
+
import json as _json
|
|
51
|
+
argv = ["curl", "-sS", "--max-time", "20"]
|
|
52
|
+
if json_body is not None:
|
|
53
|
+
argv += ["-H", "Content-Type: application/json",
|
|
54
|
+
"-d", _json.dumps(json_body, ensure_ascii=False)]
|
|
55
|
+
elif form is not None:
|
|
56
|
+
from urllib.parse import urlencode
|
|
57
|
+
argv += ["-H", "Content-Type: application/x-www-form-urlencoded",
|
|
58
|
+
"-d", urlencode(form)]
|
|
59
|
+
argv.append(url)
|
|
60
|
+
r = runner.run_process(argv=argv, timeout=30)
|
|
61
|
+
return bool(r["ok"])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _post(hook, text):
|
|
65
|
+
return _send(hook, json_body=_payload_for(hook, text))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _personal_channels():
|
|
69
|
+
"""读设置里的个人推送配置,返回 [(通道名, 发送函数(title, body)), ...]。
|
|
70
|
+
|
|
71
|
+
只收录配置完整的通道;键名与 settings.DEFAULTS 对齐。
|
|
72
|
+
"""
|
|
73
|
+
from . import settings
|
|
74
|
+
s = settings.load()
|
|
75
|
+
chs = []
|
|
76
|
+
|
|
77
|
+
bark_key = str(s.get("notify_bark_key") or "").strip()
|
|
78
|
+
if bark_key:
|
|
79
|
+
server = (str(s.get("notify_bark_server") or "").strip().rstrip("/")
|
|
80
|
+
or "https://api.day.app")
|
|
81
|
+
chs.append(("bark", lambda t, b: _send(
|
|
82
|
+
server + "/push",
|
|
83
|
+
json_body={"device_key": bark_key, "title": t, "body": b})))
|
|
84
|
+
|
|
85
|
+
ntfy = str(s.get("notify_ntfy_topic") or "").strip()
|
|
86
|
+
if ntfy:
|
|
87
|
+
from urllib.parse import urlparse
|
|
88
|
+
u = urlparse(ntfy if "://" in ntfy else "https://ntfy.sh/" + ntfy.lstrip("/"))
|
|
89
|
+
topic = u.path.strip("/")
|
|
90
|
+
if topic:
|
|
91
|
+
server = "%s://%s" % (u.scheme, u.netloc)
|
|
92
|
+
chs.append(("ntfy", lambda t, b: _send(
|
|
93
|
+
server + "/",
|
|
94
|
+
json_body={"topic": topic, "title": t, "message": b})))
|
|
95
|
+
|
|
96
|
+
sc_key = str(s.get("notify_serverchan_key") or "").strip()
|
|
97
|
+
if sc_key:
|
|
98
|
+
chs.append(("serverchan", lambda t, b: _send(
|
|
99
|
+
"https://sctapi.ftqq.com/%s.send" % sc_key,
|
|
100
|
+
form={"title": t, "desp": b})))
|
|
101
|
+
|
|
102
|
+
tg_token = str(s.get("notify_telegram_token") or "").strip()
|
|
103
|
+
tg_chat = str(s.get("notify_telegram_chat_id") or "").strip()
|
|
104
|
+
if tg_token and tg_chat:
|
|
105
|
+
chs.append(("telegram", lambda t, b: _send(
|
|
106
|
+
"https://api.telegram.org/bot%s/sendMessage" % tg_token,
|
|
107
|
+
json_body={"chat_id": tg_chat, "text": (t + "\n" + b).strip()})))
|
|
108
|
+
return chs
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _split_title(text):
|
|
112
|
+
"""首行作标题(≤40 字),其余作正文;单行文本标题正文同文。"""
|
|
113
|
+
lines = [ln for ln in str(text).splitlines() if ln.strip()]
|
|
114
|
+
title = (lines[0].strip() if lines else "CodeBee")[:40]
|
|
115
|
+
body = "\n".join(ln.strip() for ln in lines[1:]).strip() or title
|
|
116
|
+
return title, body
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def push_text_ex(text):
|
|
120
|
+
"""推一条文本到全部已配置通道,返回 {通道名: 是否成功}。
|
|
121
|
+
|
|
122
|
+
未配置任何通道返回 {};单通道失败不影响其他通道。
|
|
123
|
+
"""
|
|
124
|
+
results = {}
|
|
125
|
+
hook = _webhook()
|
|
126
|
+
if hook:
|
|
127
|
+
if not hook.startswith("https://"):
|
|
128
|
+
log.warning("notify webhook 必须是 https")
|
|
129
|
+
results["webhook"] = False
|
|
130
|
+
else:
|
|
131
|
+
try:
|
|
132
|
+
results["webhook"] = _post(hook, text)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
log.warning("notify push failed: %s", e)
|
|
135
|
+
results["webhook"] = False
|
|
136
|
+
try:
|
|
137
|
+
title, body = _split_title(text)
|
|
138
|
+
except Exception:
|
|
139
|
+
title, body = "CodeBee", str(text)
|
|
140
|
+
for name, fn in _personal_channels():
|
|
141
|
+
try:
|
|
142
|
+
results[name] = bool(fn(title, body))
|
|
143
|
+
except Exception as e:
|
|
144
|
+
log.warning("notify %s push failed: %s", name, e)
|
|
145
|
+
results[name] = False
|
|
146
|
+
return results
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def push_text(text):
|
|
150
|
+
"""推一条文本;任一通道成功即 True;全未配置/全失败返回 False,不抛错。"""
|
|
151
|
+
try:
|
|
152
|
+
return any(push_text_ex(text).values())
|
|
153
|
+
except Exception as e:
|
|
154
|
+
log.warning("notify push failed: %s", e)
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def push_run_async(run_id):
|
|
159
|
+
"""任务收尾后异步推送结果摘要(jobs 层调用;绝不阻塞/影响任务)。"""
|
|
160
|
+
threading.Thread(target=push_run, daemon=True,
|
|
161
|
+
name="notify-%s" % run_id, args=(run_id,)).start()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def push_run(run_id):
|
|
165
|
+
"""组装 run 结果摘要并推送(群 webhook 或任一个人通道配置了才推)。
|
|
166
|
+
|
|
167
|
+
摘要末尾附分享页路径(notify_base_url 设置非空时拼完整链接,否则只给
|
|
168
|
+
run_id 供在本机 CodeBee 界面查找)。"""
|
|
169
|
+
from . import store
|
|
170
|
+
run = store.get_run(run_id) or {}
|
|
171
|
+
if not run:
|
|
172
|
+
return False
|
|
173
|
+
status = str(run.get("status") or "")
|
|
174
|
+
mark = {"done": "✅", "failed": "❌", "cancelled": "⚪"}.get(status, "🔔")
|
|
175
|
+
lines = ["%s CodeBee 任务%s" % (mark, {"done": "完成", "failed": "失败",
|
|
176
|
+
"cancelled": "已取消"}.get(status, status))]
|
|
177
|
+
lines.append("任务:%s" % (run.get("title") or run_id))
|
|
178
|
+
if run.get("error"):
|
|
179
|
+
lines.append("错误:%s" % str(run["error"])[:200])
|
|
180
|
+
v = run.get("verdict") or {}
|
|
181
|
+
if v.get("overall") is not None:
|
|
182
|
+
lines.append("综合评分 %.1f(%s)" % (
|
|
183
|
+
float(v["overall"]), "达标" if v.get("publishable") else "未达标"))
|
|
184
|
+
try:
|
|
185
|
+
from .settings import load as _sload
|
|
186
|
+
base = str(_sload().get("notify_base_url") or "").strip().rstrip("/")
|
|
187
|
+
if base:
|
|
188
|
+
lines.append("📄 详情:%s/api/runs/%s/share" % (base, run_id))
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
# 分支待裁决:任务跑完有改动等人拍板时点名提醒(手机端看到即可去详情处理)
|
|
192
|
+
try:
|
|
193
|
+
task = store.get_task(run.get("task_id")) if run.get("task_id") else None
|
|
194
|
+
if task and task.get("git_state") == "isolated":
|
|
195
|
+
lines.append("⚠ 任务分支待裁决(合并/丢弃)")
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
return push_text("\n".join(lines))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
import threading # noqa: E402 (push_run_async 依赖;置底避免顶部循环导入)
|