codebee 0.1.68 → 0.1.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +580 -543
  2. package/LICENSE +21 -21
  3. package/README.md +11 -0
  4. package/app/browser-final-1440.png +0 -0
  5. package/app/browser-final-390.png +0 -0
  6. package/app/browser-google-fallback.png +0 -0
  7. package/app/browser-google-test.png +0 -0
  8. package/app/browser-workbench-1440.png +0 -0
  9. package/app/browser-workbench-390.png +0 -0
  10. package/app/browser-workspace-google.png +0 -0
  11. package/app/browser-workspace-smoke.png +0 -0
  12. package/app/browser-workspace.png +0 -0
  13. package/app/core/acceptance.py +100 -100
  14. package/app/core/aiflavor.py +197 -197
  15. package/app/core/artifacts.py +148 -148
  16. package/app/core/assets.py +110 -110
  17. package/app/core/attachments.py +623 -623
  18. package/app/core/audit.py +30 -0
  19. package/app/core/automation.py +853 -758
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +95 -0
  23. package/app/core/board.py +209 -209
  24. package/app/core/bookmeta.py +935 -930
  25. package/app/core/bookmeta_catalog.py +180 -180
  26. package/app/core/branching.py +137 -137
  27. package/app/core/builtin_agent.py +1673 -1234
  28. package/app/core/capability.py +144 -144
  29. package/app/core/catalog.py +7 -1
  30. package/app/core/chaptersafe.py +32 -32
  31. package/app/core/cleanup.py +303 -303
  32. package/app/core/compaction.py +201 -201
  33. package/app/core/covergen.py +356 -356
  34. package/app/core/defectretro.py +43 -43
  35. package/app/core/diagnostics.py +114 -114
  36. package/app/core/dispatch.py +190 -173
  37. package/app/core/dispatch_log.py +128 -128
  38. package/app/core/env_scrub.py +85 -85
  39. package/app/core/error_codes.py +33 -24
  40. package/app/core/errorlog.py +137 -137
  41. package/app/core/evalbench.py +720 -0
  42. package/app/core/evaluation.py +174 -174
  43. package/app/core/failure_notify.py +290 -290
  44. package/app/core/flows.py +228 -3
  45. package/app/core/goal_service.py +167 -167
  46. package/app/core/health.py +374 -374
  47. package/app/core/history.py +38 -38
  48. package/app/core/hooks.py +162 -162
  49. package/app/core/jobs.py +51 -30
  50. package/app/core/manager.py +2143 -2122
  51. package/app/core/market.py +362 -362
  52. package/app/core/market_remote.py +10 -0
  53. package/app/core/mcp_client.py +287 -0
  54. package/app/core/mocks.py +64 -64
  55. package/app/core/modelhub.py +193 -10
  56. package/app/core/notify.py +201 -106
  57. package/app/core/novel_quality.py +189 -0
  58. package/app/core/operations.py +228 -228
  59. package/app/core/paihang.py +138 -138
  60. package/app/core/paths.py +6 -2
  61. package/app/core/pipeline.py +426 -28
  62. package/app/core/planner.py +243 -22
  63. package/app/core/plugins.py +525 -0
  64. package/app/core/portguard.py +111 -111
  65. package/app/core/preview.py +293 -293
  66. package/app/core/publish/__init__.py +7 -7
  67. package/app/core/publish/auto.py +367 -367
  68. package/app/core/publish/browser.py +655 -539
  69. package/app/core/publish/fanqie.py +165 -159
  70. package/app/core/publish/flow.py +589 -440
  71. package/app/core/publish/flows-fanqie-calibrated.json +336 -332
  72. package/app/core/publish/ledger.py +229 -203
  73. package/app/core/publish/manager.py +824 -692
  74. package/app/core/publish/qimao.py +146 -141
  75. package/app/core/publish/ws.py +139 -139
  76. package/app/core/redact.py +38 -38
  77. package/app/core/remotesync.py +121 -0
  78. package/app/core/repeat_guard.py +129 -129
  79. package/app/core/router.py +398 -398
  80. package/app/core/runner.py +2142 -2067
  81. package/app/core/selfupdate.py +384 -384
  82. package/app/core/session_log.py +161 -161
  83. package/app/core/sessions.py +312 -312
  84. package/app/core/settings.py +186 -149
  85. package/app/core/settings_schema.py +270 -262
  86. package/app/core/share_page.py +114 -114
  87. package/app/core/skill_scan.py +122 -122
  88. package/app/core/skillpacks/fanqie-novel.md +80 -80
  89. package/app/core/skillpacks/market/character-bible.md +66 -66
  90. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  91. package/app/core/skillpacks/market/git-workflow.md +57 -57
  92. package/app/core/skillpacks/market/release-notes.md +72 -72
  93. package/app/core/skillpacks/market/weekly-report.md +71 -71
  94. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  95. package/app/core/step_runner.py +132 -132
  96. package/app/core/store.py +104 -82
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tracing.py +144 -144
  102. package/app/core/upstream.py +41 -41
  103. package/app/core/usage.py +1035 -862
  104. package/app/core/volumes.py +389 -389
  105. package/app/core/wxdigest.py +858 -858
  106. package/app/core/zentao.py +2614 -2465
  107. package/app/current-browser.png +0 -0
  108. package/app/main.py +3245 -2981
  109. package/app/mcp_server.py +175 -0
  110. package/app/pet.py +82 -21
  111. package/app/pick_dialog.py +110 -110
  112. package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
  113. package/app/plugins/code-review-helper/skills/review.md +16 -0
  114. package/app/ui/app.js +15706 -14399
  115. package/app/ui/artdiff.js +74 -74
  116. package/app/ui/board.html +162 -162
  117. package/app/ui/board.js +305 -305
  118. package/app/ui/i18n.js +3012 -2770
  119. package/app/ui/icons/bee.svg +79 -79
  120. package/app/ui/index.html +277 -13
  121. package/app/ui/manifest.json +16 -16
  122. package/app/ui/qrcode.js +2297 -2297
  123. package/app/ui/style.css +802 -16
  124. package/bin/tutti.js +147 -147
  125. package/package.json +47 -46
  126. package/tools/wx_reader.py +261 -261
  127. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  128. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  129. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  130. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  131. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  132. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  133. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -2447,21 +2447,23 @@ def binding_dead_msg(cli_id):
2447
2447
  "本步判失败、不回落 CLI 本机默认——%s请在「模型调度(可选)」页为该 CLI 指定已启用的供应商" % hint)
2448
2448
 
2449
2449
 
2450
- def resolve_binding(agent_kind_or_id, difficulty="default"):
2450
+ def resolve_binding(agent_kind_or_id, difficulty="default"):
2451
2451
  """返回 {env:{}, model:..., model_fallbacks:[...], codex_provider:..., call_chain:[...]} 或 None。
2452
2452
 
2453
2453
  call_chain 是跨厂商降级链(唯一真源):每条 {model, env, provider, [codex_provider]},
2454
2454
  按序尝试;供应商失效(停用/删除/无密钥/协议不可注入)的条目被跳过,全链失效
2455
- → 整体回落 CLI 默认(None)。纯链条目(无供应商)不注入 env,只传 -m。
2455
+ → 整体回落 CLI 默认(None)。纯链条目(无供应商)不注入 env,只传 -m,
2456
+ 并带 no_cred=True 标记供 runner 审计层点名(零凭据条目走本机登录态,
2457
+ 本机无登录态必死在凭据闸门——2026-09-25 Mac claude 实案)。
2456
2458
  难度路由只在无显式链时生效。
2457
2459
  """
2458
- # Local-credential-only CLIs must never expose a binding, including legacy
2459
- # model-only entries that have no provider_id. The generic runner cannot
2460
- # inject those model names, so returning a partial chain would make the UI
2461
- # report a bound model while the CLI silently uses its own default.
2462
- if agent_kind_or_id in _LOCAL_CRED_ONLY_TARGETS:
2463
- return None
2464
- b = _binding_for(agent_kind_or_id)
2460
+ # Local-credential-only CLIs must never expose a binding, including legacy
2461
+ # model-only entries that have no provider_id. The generic runner cannot
2462
+ # inject those model names, so returning a partial chain would make the UI
2463
+ # report a bound model while the CLI silently uses its own default.
2464
+ if agent_kind_or_id in _LOCAL_CRED_ONLY_TARGETS:
2465
+ return None
2466
+ b = _binding_for(agent_kind_or_id)
2465
2467
  chain = _binding_chain(b)
2466
2468
  provs = {p.get("id"): p for p in providers()}
2467
2469
  routing = bool(b.get("difficulty_routing"))
@@ -2487,7 +2489,11 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
2487
2489
  pid = (item.get("provider_id") or "").strip()
2488
2490
  if not pid:
2489
2491
  if model:
2490
- entries.append({"model": model, "env": {}, "provider": None})
2492
+ # 纯模型条目:零凭据注入,只传 -m 走 CLI 本机登录态。
2493
+ # no_cred 标记供 runner 审计行/登录指引点名(Mac claude 案:
2494
+ # 零注入死在登录闸门,起跑前无人知、死后只能靠 init JSON 反推)
2495
+ entries.append({"model": model, "env": {}, "provider": None,
2496
+ "no_cred": True})
2491
2497
  continue
2492
2498
  prov = provs.get(pid)
2493
2499
  if not prov or not prov.get("enabled", True) or not prov.get("api_key"):
@@ -2803,12 +2809,84 @@ def models_view():
2803
2809
  if m.get("hidden"):
2804
2810
  continue
2805
2811
  pr = pricing.get(m["name"]) or {}
2812
+ if m.get("price_in") is not None or m.get("price_out") is not None:
2813
+ pr = {"in": m.get("price_in"), "out": m.get("price_out")} # 手标优先
2806
2814
  rows.append(dict(base, name=m["name"], enabled=bool(m.get("enabled", True)),
2807
2815
  priority=m.get("priority", 0),
2808
2816
  price_in=pr.get("in"), price_out=pr.get("out")))
2809
2817
  return rows
2810
2818
 
2811
2819
 
2820
+ def model_price(model_name, provider=""):
2821
+ """模型单价(¥ / 百万 tokens):指定供应商的手标价优先,回落 CCSwitch
2822
+ 导入价表;都没有返回 None——不猜价,台账不记钱、预算不计入。
2823
+
2824
+ 手标是「按供应商」标定的(同名模型在不同网关价格可不同),因此指定
2825
+ 供应商未标价时不继承别家手标,直接看价表。
2826
+ 列名沿用台账 cost_usd 字段,语义自本函数起 = 费用(¥)。
2827
+ """
2828
+ def _manual(prov_id):
2829
+ with _LOCK:
2830
+ for p in _load().get("providers", []):
2831
+ if not prov_id or p.get("id") != prov_id:
2832
+ continue
2833
+ for m in p.get("models") or []:
2834
+ if m.get("name") != model_name:
2835
+ continue
2836
+ try:
2837
+ pin = float(m.get("price_in"))
2838
+ pout = float(m.get("price_out"))
2839
+ except (TypeError, ValueError):
2840
+ continue
2841
+ if pin >= 0 and pout >= 0 and (pin or pout):
2842
+ return {"in": pin, "out": pout}
2843
+ return None
2844
+
2845
+ try:
2846
+ pr = _manual(provider)
2847
+ if pr:
2848
+ return pr
2849
+ with _LOCK:
2850
+ raw = (_load().get("pricing") or {}).get(model_name) or {}
2851
+ pin, pout = float(raw.get("in") or 0), float(raw.get("out") or 0)
2852
+ if pin > 0 or pout > 0:
2853
+ return {"in": pin, "out": pout}
2854
+ except Exception:
2855
+ pass
2856
+ return None
2857
+
2858
+
2859
+ def set_model_price(provider_id, name, price_in, price_out):
2860
+ """手标模型单价(¥/百万 tokens;空串=清除标定,回落价表/未标价)。"""
2861
+ def _num(v):
2862
+ v = str(v if v is not None else "").strip()
2863
+ return float(v) if v != "" else None
2864
+ try:
2865
+ pin, pout = _num(price_in), _num(price_out)
2866
+ except (TypeError, ValueError):
2867
+ return "单价必须是数字"
2868
+ if (pin is not None and pin < 0) or (pout is not None and pout < 0):
2869
+ return "单价不能为负"
2870
+ with _LOCK:
2871
+ data = _load()
2872
+ prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
2873
+ if not prov:
2874
+ return "供应商不存在"
2875
+ m = next((x for x in prov.get("models") or [] if x.get("name") == name), None)
2876
+ if not m:
2877
+ return "模型不存在"
2878
+ if pin is None:
2879
+ m.pop("price_in", None)
2880
+ else:
2881
+ m["price_in"] = pin
2882
+ if pout is None:
2883
+ m.pop("price_out", None)
2884
+ else:
2885
+ m["price_out"] = pout
2886
+ _save(data)
2887
+ return None
2888
+
2889
+
2812
2890
  def reorder_models(provider_id, ordered_names):
2813
2891
  """按给定名称顺序重设优先级(列表中未出现的模型排在最后,保持相对顺序)。"""
2814
2892
  with _LOCK:
@@ -3103,6 +3181,111 @@ def test_model(provider_id, model_name, key_id=""):
3103
3181
  return finish(last)
3104
3182
 
3105
3183
 
3184
+ def generate_once(provider_id, model_name, prompt, max_tokens=2048, timeout_s=120):
3185
+ """单轮真实生成(评测基准台等需要「自定义 prompt 实答」的场景)。
3186
+
3187
+ 与 test_model 同一套 wire 解析(auto 探测/多 KEY 回退/KEY 健康记账),
3188
+ 但发的是真实生成请求并取回正文。返回
3189
+ {ok, text, usage{input,output}, protocol, key_id, latency_ms, error};
3190
+ 任何失败折叠进 ok=False + error,绝不抛出。
3191
+ """
3192
+ import time as _t
3193
+ with _LOCK:
3194
+ prov = next((p for p in providers() if p.get("id") == provider_id), None)
3195
+ if not prov:
3196
+ return {"ok": False, "text": "", "usage": None, "error": "供应商不存在"}
3197
+ keys = _chain_keys(prov)
3198
+ if not keys:
3199
+ return {"ok": False, "text": "", "usage": None, "error": "无可用 API KEY"}
3200
+ allow_private = bool(prov.get("allow_private"))
3201
+ t0 = _t.time()
3202
+
3203
+ def out(ok, text="", usage=None, protocol="", key_id="", error=""):
3204
+ return {"ok": ok, "text": text, "usage": usage, "protocol": protocol,
3205
+ "key_id": key_id, "error": error,
3206
+ "latency_ms": int((_t.time() - t0) * 1000)}
3207
+
3208
+ last_err = "无可用 wire"
3209
+ for key_index, kk in enumerate(keys):
3210
+ probe_key_id = (kk.get("id") or "") if key_index > 0 else ""
3211
+ protos, note = _ensure_wire_candidates(provider_id, prov, model_name,
3212
+ key_id=probe_key_id,
3213
+ force_probe=bool(probe_key_id))
3214
+ if not protos:
3215
+ last_err = "无已验证的兼容 wire" + ("(%s)" % note if note else "")
3216
+ note_key_error(provider_id, kk.get("id") or "", last_err)
3217
+ continue
3218
+ for proto, pbase in protos:
3219
+ base = (pbase or "").rstrip("/")
3220
+ if proto == "google":
3221
+ url = base + ("/models/%s:generateContent" % model_name
3222
+ if base.endswith("/v1beta")
3223
+ else "/v1beta/models/%s:generateContent" % model_name)
3224
+ headers = {"x-goog-api-key": kk["key"]}
3225
+ body = {"contents": [{"parts": [{"text": prompt}]}],
3226
+ "generationConfig": {"maxOutputTokens": max_tokens}}
3227
+ else:
3228
+ path = "/messages" if proto == "anthropic" else "/chat/completions"
3229
+ url = (base + path) if base.endswith("/v1") else (base + "/v1" + path)
3230
+ if proto == "anthropic":
3231
+ headers = {"x-api-key": kk["key"], "anthropic-version": "2023-06-01"}
3232
+ else:
3233
+ headers = {"Authorization": "Bearer " + kk["key"]}
3234
+ body = {"model": model_name, "max_tokens": max_tokens,
3235
+ "messages": [{"role": "user", "content": prompt}]}
3236
+ status, data, err = _post_json_http(url, headers, body, allow_private,
3237
+ timeout=timeout_s)
3238
+ if status == 0:
3239
+ last_err = err or "连接失败"
3240
+ note_key_error(provider_id, kk.get("id") or "", last_err)
3241
+ continue
3242
+ if not (200 <= status < 300):
3243
+ last_err = "HTTP %s %s" % (status, _response_error_text(data) or "")
3244
+ note_key_error(provider_id, kk.get("id") or "", last_err)
3245
+ continue
3246
+ text, usage_d = _extract_generation(proto, data)
3247
+ if not (text or "").strip():
3248
+ last_err = "HTTP 200 空响应"
3249
+ note_key_error(provider_id, kk.get("id") or "", last_err)
3250
+ continue
3251
+ note_key_ok(provider_id, kk.get("id") or "")
3252
+ return out(True, text, usage_d, proto, kk.get("id") or "")
3253
+ return out(False, error=last_err)
3254
+
3255
+
3256
+ def _extract_generation(proto, data):
3257
+ """从三族 wire 的生成响应里取正文与 token 细分。取不到返回 ("", None)。"""
3258
+ if not isinstance(data, dict):
3259
+ return "", None
3260
+ text, usage_d = "", None
3261
+ try:
3262
+ if proto == "openai":
3263
+ msg = ((data.get("choices") or [{}])[0] or {}).get("message") or {}
3264
+ c = msg.get("content")
3265
+ text = c if isinstance(c, str) else "".join(
3266
+ p.get("text") or "" for p in c) if isinstance(c, list) else ""
3267
+ u = data.get("usage") or {}
3268
+ usage_d = {"input": u.get("prompt_tokens") or 0,
3269
+ "output": u.get("completion_tokens") or 0}
3270
+ elif proto == "anthropic":
3271
+ text = "".join(b.get("text") or "" for b in data.get("content") or []
3272
+ if isinstance(b, dict) and b.get("type") == "text")
3273
+ u = data.get("usage") or {}
3274
+ usage_d = {"input": u.get("input_tokens") or 0,
3275
+ "output": u.get("output_tokens") or 0}
3276
+ elif proto == "google":
3277
+ cand = ((data.get("candidates") or [{}])[0] or {})
3278
+ text = "".join(p.get("text") or "" for p in (cand.get("content") or {}).get("parts") or [])
3279
+ u = data.get("usageMetadata") or {}
3280
+ usage_d = {"input": u.get("promptTokenCount") or 0,
3281
+ "output": u.get("candidatesTokenCount") or 0}
3282
+ except Exception:
3283
+ pass
3284
+ if usage_d is not None and not any(isinstance(v, (int, float)) for v in usage_d.values()):
3285
+ usage_d = None
3286
+ return text, usage_d
3287
+
3288
+
3106
3289
  # ---------------------------------------------------------------- wire 协议适配
3107
3290
  # 聚合中转网关(new-api/one-api 系)通常同一密钥同时开 openai(/chat/completions)
3108
3291
  # 与 anthropic(/v1/messages) 两面 wire。在「模型接入」页用 1 token 最小对话实测,
@@ -1,106 +1,201 @@
1
- # -*- coding: utf-8 -*-
2
- """运行结果群推送(借鉴 agency-orchestrator 的 --notify):任务跑完把结果摘要
3
- 推到钉钉/飞书/企业微信群机器人,配合定时自动化就是「AI 团队每天定点交活」。
4
-
5
- webhook 一个地址全包——按域名自动适配三种机器人格式(同 agency-orchestrator
6
- 思路);也接受任意 https 地址(按钉钉 text 形状发,自建 n8n 等自选)。
7
- 推送在后台线程、失败只记日志——通知永远不影响任务本身。
8
- """
9
- from __future__ import annotations
10
-
11
- import logging
12
-
13
- log = logging.getLogger(__name__)
14
-
15
-
16
- def _payload_for(webhook, text):
17
- """按 webhook 域名适配群机器人消息体。返回 None = 无法识别的地址。"""
18
- from urllib.parse import urlparse
19
- host = (urlparse(webhook).hostname or "").lower()
20
- if "dingtalk" in host:
21
- return {"msgtype": "text", "text": {"content": text}}
22
- if "feishu" in host or "larksuite" in host:
23
- return {"msg_type": "text", "content": {"text": text}}
24
- if "weixin" in host or "wechat" in host or "work.weixin" in host:
25
- return {"msgtype": "text", "text": {"content": text}}
26
- if host:
27
- return {"msgtype": "text", "text": {"content": text}} # 未知域名按钉钉形状
28
- return None
29
-
30
-
31
- def _webhook():
32
- try:
33
- from . import settings
34
- return str(settings.load().get("notify_webhook") or "").strip()
35
- except Exception:
36
- return ""
37
-
38
-
39
- def _post(hook, text):
40
- """curl 子进程 POST(Python 不经手响应体);网络失败只记日志。"""
41
- from . import runner
42
- import json as _json
43
- body = _json.dumps(_payload_for(hook, text), ensure_ascii=False)
44
- r = runner.run_process(
45
- argv=["curl", "-sS", "--max-time", "20",
46
- "-H", "Content-Type: application/json",
47
- "-d", body, hook],
48
- timeout=30)
49
- return bool(r["ok"])
50
-
51
-
52
- def push_text(text):
53
- """推一条文本到群。配置了 webhook 才推;失败返回 False 不抛错。"""
54
- hook = _webhook()
55
- if not hook:
56
- return False
57
- if not hook.startswith("https://"):
58
- log.warning("notify webhook 必须是 https")
59
- return False
60
- try:
61
- return _post(hook, text)
62
- except Exception as e:
63
- log.warning("notify push failed: %s", e)
64
- return False
65
-
66
-
67
- def push_run_async(run_id):
68
- """任务收尾后异步推送结果摘要(jobs 层调用;绝不阻塞/影响任务)。"""
69
- threading.Thread(target=push_run, daemon=True,
70
- name="notify-%s" % run_id, args=(run_id,)).start()
71
-
72
-
73
- def push_run(run_id):
74
- """组装 run 结果摘要并推送(webhook 未配置时静默跳过)。
75
-
76
- 摘要末尾附分享页路径(notify_base_url 设置非空时拼完整链接,否则只给
77
- run_id 供在本机 CodeBee 界面查找)。"""
78
- hook = _webhook()
79
- if not hook:
80
- return False
81
- from . import store
82
- run = store.get_run(run_id) or {}
83
- if not run:
84
- return False
85
- status = str(run.get("status") or "")
86
- mark = {"done": "✅", "failed": "❌", "cancelled": "⚪"}.get(status, "🔔")
87
- lines = ["%s CodeBee 任务%s" % (mark, {"done": "完成", "failed": "失败",
88
- "cancelled": "已取消"}.get(status, status))]
89
- lines.append("任务:%s" % (run.get("title") or run_id))
90
- if run.get("error"):
91
- lines.append("错误:%s" % str(run["error"])[:200])
92
- v = run.get("verdict") or {}
93
- if v.get("overall") is not None:
94
- lines.append("综合评分 %.1f(%s)" % (
95
- float(v["overall"]), "达标" if v.get("publishable") else "未达标"))
96
- try:
97
- from .settings import load as _sload
98
- base = str(_sload().get("notify_base_url") or "").strip().rstrip("/")
99
- if base:
100
- lines.append("📄 详情:%s/api/runs/%s/share" % (base, run_id))
101
- except Exception:
102
- pass
103
- return push_text("\n".join(lines))
104
-
105
-
106
- 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 依赖;置底避免顶部循环导入)