@gavinc-cn/touchstone-dsh 0.1.0 → 0.2.0

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 (58) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +22 -1
  3. package/README.zh.md +12 -1
  4. package/agents.py +0 -0
  5. package/auth.py +0 -0
  6. package/board.py +1119 -93
  7. package/builtin_assets.py +0 -0
  8. package/builtin_prompts/free_style/flow.md +0 -0
  9. package/builtin_prompts/free_style/load_test.md +0 -0
  10. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  11. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  12. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  13. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  14. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  15. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  16. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  17. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  18. package/chat.py +590 -17
  19. package/db.py +63 -12
  20. package/dsh-plugin/cordis.patch.yml +7 -3
  21. package/dsh-plugin/install.sh +8 -5
  22. package/dsh-plugin/lib/agent-driver.js +895 -126
  23. package/dsh-plugin/lib/client.bundle.js +94 -9
  24. package/dsh-plugin/lib/index.js +399 -20
  25. package/dsh-plugin/scripts/build.mjs +0 -0
  26. package/dsh-plugin/scripts/prepack-check.mjs +0 -0
  27. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  28. package/dsh-plugin/src/client.js +94 -9
  29. package/dshdriver.py +74 -5
  30. package/dshevents.py +100 -6
  31. package/export_cases.py +0 -0
  32. package/extensions/dsh-autocommit/asset.json +0 -0
  33. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  34. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  35. package/feishu.py +1226 -42
  36. package/feishu_conv.py +920 -0
  37. package/lib.py +0 -0
  38. package/lifecycle.py +0 -0
  39. package/loadcase.py +0 -0
  40. package/loadgen.py +0 -0
  41. package/localbus.py +0 -0
  42. package/migrate_p7b.py +0 -0
  43. package/package.json +8 -3
  44. package/platcompat.py +0 -0
  45. package/prompts.py +0 -0
  46. package/rag.py +0 -0
  47. package/requirements.txt +2 -1
  48. package/runner.py +11 -0
  49. package/server.py +264 -30
  50. package/sessparse.py +244 -44
  51. package/touchstone.py +8 -4
  52. package/waitq.py +23 -0
  53. package/webui/dist-plugin/assets/index-JLwyZZxC.js +484 -0
  54. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +1 -0
  55. package/webui/dist-plugin/index.html +2 -2
  56. package/worktree.py +100 -0
  57. package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
  58. package/webui/dist-plugin/assets/index-eHpxBfP6.js +0 -473
package/feishu.py CHANGED
@@ -25,9 +25,14 @@ import urllib.error
25
25
  import urllib.request
26
26
 
27
27
  import db
28
+ import feishu_conv
28
29
  import requests
29
30
 
30
31
  FEISHU_EVENTS = ("blocked_interaction", "task_failed", "card_review")
32
+ # 未单独绑定项目时的默认事件集(与设置页 `GET /api/projects/<pid>/feishu-hook` 的
33
+ # 默认回显一致:交互等待/任务失败开、卡片待审核默认关)。2026-10-10 之前回落分支
34
+ # 写死全事件 ⇒ 界面显示「未勾选」而实际会推,是与设置页不一致的实障来源。
35
+ FEISHU_DEFAULT_EVENTS = ("blocked_interaction", "task_failed")
31
36
  RETRY_DELAYS = (60, 300, 1800) # 重试退避(秒):3 次后标 failed 留痕
32
37
  SEND_TIMEOUT = 5 # webhook POST 超时(秒)
33
38
  _TICK = 1.0 # 发送线程轮询间隔(秒)
@@ -52,6 +57,17 @@ _MQ_TTL = 1800 # 暂存存活上限(秒):超时即丢,用户重
52
57
  _MQ_MAX_BUCKETS = 64 # 暂存桶上限(卡×提问):极端情况下防无界增长
53
58
  _MQ_FORM_MAX_BYTES = 950 # 单题多选 mini 表单 JSON 预算(飞书 form 元素硬限 1000)
54
59
 
60
+ # 不可远程作答(board._iw_interaction 的 `answerable=false`)时的**统一引导文案**
61
+ # (2026-10-07 纠偏)。这类等待的提问由**非平台自持会话**发起——用户在 dsh GUI 里
62
+ # 直跑的会话,以及宿主重启丢掉会话池身份后由 GUI 续跑的会话;此时插件只旁听、
63
+ # 挂起标记的 call_id 为空 ⇒ 站点会话窗(webui SessionView 按 answerable 门控选项
64
+ # 与「提交」)与飞书卡片都只展示、不出作答控件,**唯一能作答的地方是 dsh 宿主的
65
+ # 会话窗口**。旧文案「请到站点会话窗口处理」把用户引到同样答不了的地方,故统一
66
+ # 换成这段(旧文案出现的其余几处属于「可作答但飞书无处可点」的场合,站点会话窗
67
+ # 确实可答,保持原样不动)。
68
+ _NO_REMOTE_ANSWER_HINT = ("该等待不支持远程作答(提问由 dsh 直跑会话发起),"
69
+ "请在 dsh 会话窗口作答")
70
+
55
71
 
56
72
  def user_config(user_id):
57
73
  """用户飞书配置(feishu_user_cfgs 表;enabled/base_url/默认 webhook/应用凭据)。
@@ -59,32 +75,71 @@ def user_config(user_id):
59
75
  return db.get_feishu_user_cfg(user_id)
60
76
 
61
77
 
78
+ def notify_events(project_id):
79
+ """项目级推送事件闸门(**全通道共用**,2026-10-10 修「设置不生效」实障)。
80
+
81
+ 群 webhook 文本推送(`push_event`)与飞书单聊交互卡片(`_dm_interaction_card`)
82
+ 都只按本函数放行——此前单聊卡片是旁路发送、不受任何项目开关约束,用户把
83
+ 「启用推送」与全部事件取消勾选后仍持续收到推送。
84
+
85
+ 口径(先到先算):
86
+ - **用户级总控 `push_enabled`**(设置页「飞书推送总控」,2026-10-10 新增):
87
+ 关 ⇒ 返回空集,**一票否决**——项目行开着也照样全通道静默。它必须先算:
88
+ 旧用户级 `enabled` 只在下面的「无绑定行回落」分支参与判定,项目有绑定行时
89
+ 完全不看它,故用户关掉旧开关仍会收到作答卡(实障观感「设置了不生效」)。
90
+ 缺键按开处理(`.get(..., True)`)⇒ 存量配置升级后行为不变,零迁移。
91
+ - 有绑定行:`enabled=0` ⇒ **该项目全通道静默**(显式关闭,不回落用户配置);
92
+ `enabled=1` ⇒ 取行内勾选的事件(可为空集=都不推)。
93
+ - 无绑定行:回落项目所有者的默认集 `FEISHU_DEFAULT_EVENTS`,并受其用户级
94
+ 旧开关 `enabled` 约束(关 ⇒ 空集)。项目被删(取不到 owner)时按 user_id=0
95
+ 的配置判定,与 `hook_of` 同口径。
96
+
97
+ 「按类别」开关不在本函数里另开用户级字段:项目行的 `events` 勾选即可分类
98
+ (作答卡片=`blocked_interaction`),且群与单聊卡片共用同一份。
99
+
100
+ 返回 set[str];空集 = 该项目不推任何事件。
101
+ """
102
+ row = db.get_feishu_hook(project_id)
103
+ proj = db.get_project(project_id)
104
+ owner_id = proj["user_id"] if proj else 0
105
+ cfg = user_config(owner_id)
106
+ if not cfg.get("push_enabled", True): # 用户级总控:一票否决(先于项目行判定)
107
+ return set()
108
+ if row is not None:
109
+ if not row["enabled"]:
110
+ return set()
111
+ return {e for e in FEISHU_EVENTS if e in (row["events"] or "")}
112
+ if not cfg.get("enabled", True):
113
+ return set()
114
+ return set(FEISHU_DEFAULT_EVENTS)
115
+
116
+
62
117
  def hook_of(project_id):
63
- """项目推送目的地:绑定行(enabled 且有 url)优先;显式 enabled=0 硬关闭
64
- (不回落用户配置);无绑定行或行内无 url 回落**项目所有者**的用户级配置
65
- (其 enabled 总开关 + 默认 webhook)。附 base_url(通知「详情」链接)与
66
- user_id(投递记录归属)。
118
+ """项目推送**群 webhook 目的地**:绑定行 URL 优先;行内无 url(或无绑定行)时
119
+ 回落**项目所有者**的用户级默认 webhook(回落要求其总开关 enabled 开着且配了
120
+ `default_webhook`)。附 base_url(通知「详情」链接)与 user_id(投递记录归属)。
121
+
122
+ 事件过滤不在这里判断——一律取 `notify_events`(群与单聊卡片同一闸门),
123
+ 事件集为空即视为不推;这样「回落默认 webhook」时也尊重项目的事件勾选
124
+ (2026-10-10 前回落分支写死全事件)。
67
125
  返回 {"target","secret","events","base_url","user_id"} 或 None(不推)。"""
126
+ events = notify_events(project_id)
127
+ if not events:
128
+ return None
68
129
  row = db.get_feishu_hook(project_id)
69
130
  proj = db.get_project(project_id) # 所有者为 0 时(项目被删)回落 user_config(0)={}
70
131
  owner_id = proj["user_id"] if proj else 0
71
132
  base_url = (user_config(owner_id).get("base_url") or "").rstrip("/") if proj else ""
72
- if row is not None:
73
- if not row["enabled"]:
74
- return None
75
- if row["webhook_url"]:
76
- return {"target": row["webhook_url"],
77
- "secret": row["webhook_secret"] or "",
78
- "events": {e for e in FEISHU_EVENTS
79
- if e in (row["events"] or "")},
80
- "base_url": base_url, "user_id": owner_id}
133
+ if row is not None and row["webhook_url"]:
134
+ return {"target": row["webhook_url"], "secret": row["webhook_secret"] or "",
135
+ "events": events, "base_url": base_url, "user_id": owner_id}
81
136
  cfg = user_config(owner_id)
82
137
  if not cfg.get("enabled", True):
83
138
  return None
84
139
  if not cfg.get("default_webhook"):
85
140
  return None
86
141
  return {"target": cfg["default_webhook"], "secret": cfg.get("default_secret", ""),
87
- "events": set(FEISHU_EVENTS), "base_url": base_url,
142
+ "events": events, "base_url": base_url,
88
143
  "user_id": owner_id}
89
144
 
90
145
 
@@ -139,8 +194,10 @@ def _render(kind, ctx, project_id, base_url=""):
139
194
  if kind == "blocked_interaction":
140
195
  n_q = len(qs) or ctx.get("questions_count") or 0
141
196
  if not ctx.get("answerable"):
142
- # 不可远程作答的等待(answerable=false):仍引导站点
143
- lines.append("请到站点会话窗口处理")
197
+ # 不可远程作答的等待(answerable=false):引导回 dsh 会话窗口——
198
+ # 站点会话窗此时同样不出作答控件,旧文案「请到站点会话窗口处理」
199
+ # 会把用户引到死胡同(见 _NO_REMOTE_ANSWER_HINT)
200
+ lines.append(_NO_REMOTE_ANSWER_HINT)
144
201
  elif n_q > 1:
145
202
  # 多子题:一条指令按题号逐题答全(表单卡超 form 体积硬限时同样走这条)
146
203
  lines.append(_multi_answer_help(cid, qs or [None] * int(n_q)))
@@ -440,6 +497,19 @@ def _build_interaction_card(ctx, project_name):
440
497
  kind = ctx.get("kind")
441
498
  cid = ctx.get("card_id")
442
499
  elements = []
500
+ # M6(2026-10-10):方案先上——用户报障"仅凭选项无法决策",故题面之前先给
501
+ # 折叠面板(Card 2.0 `collapsible_panel`;内部只放 markdown,飞书规定不能放
502
+ # form)+ 完整方案文档链接。两个字段都由 `_attach_plan` 挂在 ctx 上,
503
+ # 取不到方案(无会话/读不到转录)时整块不出现,卡片形状与旧版一致。
504
+ if ctx.get("plan_inline") and kind != "approval":
505
+ elements.append({
506
+ "tag": "collapsible_panel", "expanded": True,
507
+ "header": {"title": {"tag": "plain_text",
508
+ "content": "📄 agent 的方案 / 回答(点标题可折叠)"}},
509
+ "elements": [{"tag": "markdown", "content": ctx["plan_inline"]}]})
510
+ if ctx.get("plan_doc_url") and kind != "approval":
511
+ elements.append({"tag": "markdown",
512
+ "content": f"📄 [查看完整方案(飞书文档)]({ctx['plan_doc_url']})"})
443
513
  if kind == "approval":
444
514
  lines = [f"**审批请求**:{ctx.get('action') or ctx.get('tool') or '工具调用'}"]
445
515
  if ctx.get("tool"):
@@ -472,12 +542,12 @@ def _build_interaction_card(ctx, project_name):
472
542
  "content": _multi_click_hint(cid, qs, text_only,
473
543
  forms)})
474
544
  else:
475
- # 不可远程作答(会话非平台自持):逐题只读展示 + 站点引导。
545
+ # 不可远程作答(会话非平台自持):逐题只读展示 + dsh 会话窗口引导。
476
546
  # 2026-10-06 前的实现落到下面的单题分支,只渲染首题(q0),其余
477
547
  # 子题在飞书上完全不可见(用户实障:agent 一次问 3 题只见第 1 题)。
478
548
  elements.extend(_question_card_blocks(views))
479
549
  elements.append({"tag": "markdown",
480
- "content": "该等待不支持远程作答,请到站点会话窗口处理"})
550
+ "content": _NO_REMOTE_ANSWER_HINT})
481
551
  else:
482
552
  md = [f"**agent 提问**:{ctx.get('question') or 'agent 正在等待你的回答'}",
483
553
  f"卡片 #{cid} {(ctx.get('title') or '')[:60]}"]
@@ -486,7 +556,8 @@ def _build_interaction_card(ctx, project_name):
486
556
  elements.append({"tag": "markdown", "content": "\n".join(md)})
487
557
  hint = None
488
558
  if not ctx.get("answerable"):
489
- hint = "该等待不支持远程作答,请到站点会话窗口处理"
559
+ # 不可远程作答:站点会话窗也答不了(见 _NO_REMOTE_ANSWER_HINT)
560
+ hint = _NO_REMOTE_ANSWER_HINT
490
561
  elif (ctx.get("questions_count") or len(qs) or 0) > 1:
491
562
  hint = (f"该提问含 {ctx.get('questions_count') or len(qs)} 道子题,"
492
563
  "请到站点会话窗口逐题作答")
@@ -514,11 +585,369 @@ def _build_interaction_card(ctx, project_name):
514
585
  "body": {"elements": elements}}
515
586
 
516
587
 
588
+ # ---------- M6 作答方案:卡内摘要 + 完整方案飞书云文档(2026-10-10) ----------
589
+ #
590
+ # 用户报障(2026-10-10):飞书作答卡片上只有题面(question 被 `_Q_TEXT_MAX=500`
591
+ # 截断)与选项,agent 的方案/回答(末条 assistant 文本,实测 0–3217 字)与思考
592
+ # (think,实测 122–18965 字)**一个字都不发** ⇒ 用户"仅凭选项无法决策"。
593
+ # 本批把方案送进飞书,两条腿:
594
+ # ① 卡内折叠面板(`collapsible_panel`,Card 2.0)给摘要——不用跳转就能决策;
595
+ # ② 完整方案建飞书云文档(提问**全文** + 全部子题/选项/描述 + 回答 (+思考摘录)),
596
+ # 链接放卡上——卡片放不下的部分在这里补全。
597
+ #
598
+ # 为什么不在文档里直接放选项让人点(2026-10-10 取证结论):docx 块类型清单里没有
599
+ # 投票/单选交互块(`lark-oapi` 生成的 `Block` 模型 63 个块字段无 poll/vote);
600
+ # 「文件编辑」事件 `P2DriveFileEditV1` 只带 file_token/操作者、**不含改了哪一块**;
601
+ # 唯一"文档内点选"路线是内嵌多维表格 + `drive.file.bitable_record_changed_v1`
602
+ # 事件(或文档评论事件),都要建表/授权/事件订阅 + 轮询,不适合"挂起等作答"的
603
+ # 实时链路。故选项留在卡片上,文档承载方案与选项说明(文档内给文本补答语法)。
604
+
605
+ PLAN_ENV = "TS_FEISHU_PLAN" # =0 关闭本批全部新行为(回滚阀,回到只有题面+选项)
606
+ _PLAN_INLINE_MAX = 1500 # 卡内折叠摘要上限(字符)
607
+ _PLAN_DOC_MAX = 20000 # 文档正文上限(字符)
608
+ _PLAN_THINK_MIN = 200 # assistant 回答短于该值 ⇒ 补 think 摘录
609
+ _PLAN_THINK_MAX = 8000 # think 摘录上限(字符)
610
+ _PLAN_DOC_TTL = 1800 # 建文档去重记录存活(秒)
611
+ _PLAN_DOCS = {} # (card_id, qid) -> {"url": str, "at": float}
612
+ _MD_BLOCK_MAX = 1800 # 文档单块文本上限(字符,超长块会被接口拒收)
613
+ _ASK_TOOL = "ask_user_question" # dsh 提问工具名("提问前上下文"的切点)
614
+ _DOC_TIMEOUT = 8 # 文档类调用逐次超时(秒)
615
+
616
+
617
+ def plan_enabled():
618
+ """作答方案增强总闸:`TS_FEISHU_PLAN=0` 关闭(一行回到"只有题面+选项"的现状)。"""
619
+ return (os.environ.get(PLAN_ENV) or "1") != "0"
620
+
621
+
622
+ def _beijing_now():
623
+ """北京时间字符串:容器本地时区=UTC、用户/浏览器=Asia/Shanghai,给人看的文档
624
+ 一律按 UTC+8 落(否则 22:00 的提问会写成 14:00,读者会误判成"8 小时前")。"""
625
+ return time.strftime("%Y-%m-%d %H:%M", time.gmtime(time.time() + 8 * 3600))
626
+
627
+
628
+ def _plan_session_texts(sid):
629
+ """会话转录 → 提问前的「方案/回答」文本 `{"assistant", "think"}`。
630
+
631
+ 取「最后一次 `ask_user_question` **之前**」的末条 assistant 与末条 think——
632
+ 多轮提问时只取当前这次提问的上下文(更早的回答不是本次的方案)。转录里还没有
633
+ 该 tool_call(落盘滞后 / 旧会话)时回落整段末尾,语义相同(末尾即提问前内容)。
634
+
635
+ 无 sid / 会话读不到 / 解析异常 ⇒ 两项皆空串:方案取数失败绝不影响卡片发送。
636
+ """
637
+ empty = {"assistant": "", "think": ""}
638
+ sid = str(sid or "").strip()
639
+ if not sid:
640
+ return empty
641
+ try:
642
+ import sessparse # 函数内懒 import:避免模块级循环依赖
643
+ res = sessparse.load("dsh", sid, "main") or {}
644
+ entries = res.get("entries") or []
645
+ if not res.get("found") or not entries:
646
+ return empty
647
+ cut = len(entries)
648
+ for i in range(len(entries) - 1, -1, -1):
649
+ e = entries[i] or {}
650
+ if e.get("kind") == "tool_call" and e.get("name") == _ASK_TOOL:
651
+ cut = i
652
+ break
653
+ assistant = think = ""
654
+ for e in reversed(entries[:cut]):
655
+ e = e or {}
656
+ if e.get("kind") == "assistant" and not assistant:
657
+ assistant = str(e.get("text") or "").strip()
658
+ elif e.get("kind") == "think" and not think:
659
+ think = str(e.get("text") or "").strip()
660
+ if assistant and think:
661
+ break
662
+ return {"assistant": assistant, "think": think}
663
+ except Exception:
664
+ return empty
665
+
666
+
667
+ def _plan_inline_markdown(ctx, texts):
668
+ """卡内折叠摘要(上限 `_PLAN_INLINE_MAX`):以末条 assistant 回答为主。
669
+
670
+ assistant 过短(< `_PLAN_THINK_MIN`)时补 think 摘录——实测这是常见形态:
671
+ 回答只有一句"我给一份方案,你确认后再动手"、方案本体在 think 里(卡 949:
672
+ assistant 100 字 vs think 3513 字),不补的话卡上仍等于没有方案。
673
+ """
674
+ a = str((texts or {}).get("assistant") or "").strip()
675
+ t = str((texts or {}).get("think") or "").strip()
676
+ parts = []
677
+ if a:
678
+ parts.append(a)
679
+ if len(a) < _PLAN_THINK_MIN and t:
680
+ parts.append("**(思考摘录)**\n" + t)
681
+ if not parts:
682
+ return ""
683
+ md = "\n\n".join(parts)
684
+ if len(md) > _PLAN_INLINE_MAX:
685
+ md = md[:_PLAN_INLINE_MAX].rstrip() + "\n\n…(摘要已截断,完整内容见下方飞书文档)"
686
+ return md
687
+
688
+
689
+ def _plan_question_md(ctx):
690
+ """提问**全文**(不截断)→ markdown:逐题题头/题面/补充说明/选项与描述/能力标注。
691
+
692
+ 与卡片渲染(`_question_views` 按 `_Q_TEXT_MAX`/`_Q_OPT_MAX`/`_Q_DESC_MAX` 截断)
693
+ 刻意分开:文档承载的正是卡片放不下的完整信息。旧调用面/测试替身只给平面字段
694
+ (question + 字符串选项)时按平面字段兜底。
695
+ """
696
+ qs = [q for q in (ctx.get("questions") or []) if q]
697
+ lines = []
698
+ if not qs:
699
+ q = str(ctx.get("question") or "").strip()
700
+ if q:
701
+ lines.append(q)
702
+ for i, o in enumerate(ctx.get("options") or [], 1):
703
+ lines.append(f"{i}) {_option_view(o)['label']}")
704
+ return "\n".join(lines)
705
+ n = len(qs)
706
+ for i, q in enumerate(qs, 1):
707
+ title = str(q.get("header") or q.get("question") or f"第 {i} 题")
708
+ lines.append(f"### {i}/{n} {title}")
709
+ if q.get("header") and q.get("question"):
710
+ lines.append(str(q["question"]))
711
+ body = str(q.get("body") or "").strip()
712
+ if body:
713
+ lines.append(body)
714
+ for j, o in enumerate(q.get("options") or [], 1):
715
+ ov = _option_view(o)
716
+ lines.append(f"{j}) {ov['label']}")
717
+ if ov["description"]:
718
+ lines.append(f"  {ov['description']}") # 全角缩进:描述次行
719
+ caps = []
720
+ if q.get("multi_select"):
721
+ caps.append("多选")
722
+ if q.get("allow_other"):
723
+ caps.append(f"可自定义:{q.get('other_label') or '其他'}")
724
+ if caps:
725
+ lines.append("(" + ";".join(caps) + ")")
726
+ return "\n".join(lines)
727
+
728
+
729
+ def _plan_doc_title(ctx):
730
+ """文档标题:卡号 + 题头(云空间列表里能一眼认出是哪张卡哪次提问)。"""
731
+ head = str(ctx.get("header") or ctx.get("question") or "提问").strip()
732
+ head = (head.splitlines() or [""])[0]
733
+ return _clip_text(f"卡 {ctx.get('card_id')} · {head}", 80)
734
+
735
+
736
+ def _plan_doc_markdown(ctx, texts, project_name=""):
737
+ """完整方案文档正文(markdown,经 `_md_blocks` 转飞书块)。
738
+
739
+ 含:提问**全文**(卡片截断的部分在此补全)+ 末条 assistant 回答 + 作答指引
740
+ (文档内也能凭"回复序号"作答)+ 必要时的 think 摘录;整体按 `_PLAN_DOC_MAX` 截断。
741
+ """
742
+ cid = ctx.get("card_id")
743
+ a = str((texts or {}).get("assistant") or "").strip()
744
+ t = str((texts or {}).get("think") or "").strip()
745
+ lines = [f"# 卡 {cid} · agent 的方案与提问", "",
746
+ f"- 项目:{project_name or '—'}",
747
+ f"- 卡片:{str(ctx.get('title') or '')[:60]}",
748
+ f"- 时间:{_beijing_now()}(北京时间)"]
749
+ sid = str(ctx.get("session_id") or "").strip()
750
+ if sid:
751
+ lines.append(f"- 会话:{sid}")
752
+ if a:
753
+ lines += ["", "## agent 的方案 / 回答", "", a]
754
+ lines += ["", "## 提问(全文)", "", _plan_question_md(ctx)]
755
+ lines += ["", "## 如何作答", "",
756
+ "- 在飞书聊天里点卡片上的选项按钮 / 下拉即完成作答(多选题勾选后点「提交」);",
757
+ f"- 也可以直接回复:「作答 {cid} <序号>」(多选逗号分隔,如 `作答 {cid} 1,3`),"
758
+ f"或「作答 {cid} <自定义文字>」写自由文本;",
759
+ "- 点选与文本作答可混用,已点选的题不必重复给。"]
760
+ if len(a) < _PLAN_THINK_MIN and t:
761
+ lines += ["", "## agent 的思考摘录", "", t[:_PLAN_THINK_MAX]]
762
+ md = "\n".join(lines)
763
+ if len(md) > _PLAN_DOC_MAX:
764
+ md = md[:_PLAN_DOC_MAX] + "\n\n…(正文已截断)"
765
+ return md
766
+
767
+
768
+ def _md_plain(s):
769
+ """清掉 docx 文本块**不渲染**的行内 markdown 记号(`**粗**`/`*斜*`/反引号/`~~删~~`):
770
+ 留着只会以字面量出现在文档里(文档接口不做 markdown 渲染)。"""
771
+ s = re.sub(r"\*\*(.+?)\*\*", r"\1", s)
772
+ s = re.sub(r"(?<!\*)\*([^*\n]+?)\*(?!\*)", r"\1", s)
773
+ s = re.sub(r"~~(.+?)~~", r"\1", s)
774
+ return s.replace("`", "")
775
+
776
+
777
+ def _md_blocks(text):
778
+ """极简 markdown → 飞书文档块(标题/项目符号/有序/引用/代码/分隔线/正文)。
779
+
780
+ - 强调符清掉(`_md_plain`);表格分隔行(`| --- | --- |`)丢弃——飞书块不支持
781
+ 表格,留着只有噪声;数据行按普通文本保留。
782
+ - 超长段落按 `_MD_BLOCK_MAX` 切块(单块过长会被文档接口拒收)。
783
+ - 产出恒为合法块形状,绝不抛(方案文档失败不该拖垮发卡链路)。
784
+ """
785
+ blocks, in_code, buf = [], False, []
786
+
787
+ def mk(bt, key, content):
788
+ return {"block_type": bt,
789
+ key: {"elements": [{"text_run": {
790
+ "content": content,
791
+ "text_element_style": {"bold": bt in (3, 4, 5)}}}],
792
+ "style": {}}}
793
+
794
+ def add_text(bt, key, s):
795
+ if not s:
796
+ return
797
+ for i in range(0, len(s), _MD_BLOCK_MAX):
798
+ blocks.append(mk(bt, key, s[i:i + _MD_BLOCK_MAX]))
799
+
800
+ def flush_code():
801
+ if buf:
802
+ add_text(14, "code", "\n".join(buf))
803
+ buf.clear()
804
+
805
+ for raw in str(text or "").split("\n"):
806
+ line = raw.rstrip()
807
+ if line.strip().startswith("```"):
808
+ if in_code:
809
+ flush_code()
810
+ in_code = False
811
+ else:
812
+ in_code = True
813
+ continue
814
+ if in_code:
815
+ buf.append(line)
816
+ continue
817
+ s = _md_plain(line)
818
+ if not s.strip():
819
+ continue
820
+ if re.match(r"^\|[\s\-:|]+\|$", s.strip()): # 表格分隔行:丢
821
+ continue
822
+ if s.startswith("### "):
823
+ add_text(5, "heading3", s[4:])
824
+ elif s.startswith("## "):
825
+ add_text(4, "heading2", s[3:])
826
+ elif s.startswith("# "):
827
+ add_text(3, "heading1", s[2:])
828
+ elif s.strip() == "---":
829
+ blocks.append({"block_type": 22, "divider": {}})
830
+ elif s.lstrip().startswith("> "):
831
+ add_text(15, "quote", s.lstrip()[2:])
832
+ elif re.match(r"^\s*[-*] ", s):
833
+ add_text(12, "bullet", re.sub(r"^\s*[-*] ", "", s))
834
+ elif re.match(r"^\s*\d+[.)] ", s):
835
+ add_text(13, "ordered", re.sub(r"^\s*\d+[.)] ", "", s))
836
+ else:
837
+ add_text(2, "text", s)
838
+ flush_code()
839
+ return blocks
840
+
841
+
842
+ def _docx_create(title, md_text, cfg, open_id, timeout=_DOC_TIMEOUT):
843
+ """建飞书云文档并授权给用户 → 返回规范链接(任一步失败抛 FeishuRestError)。
844
+
845
+ 配方(2026-10-10 真机实测通过):
846
+ ① 建文档 `POST /docx/v1/documents`(需 `docx:document` 权限);
847
+ ② 写正文 `POST /docx/v1/documents/{doc}/blocks/{doc}/children`(**单次 ≤50 块**);
848
+ ③ `PATCH /drive/v1/permissions/{doc}/public?type=docx` 设「组织内可阅读」
849
+ ——**必做**:文档默认归应用所有,不做这步用户打不开;
850
+ ④ `POST /drive/v1/permissions/{doc}/members` 把用户加成协作者(**best-effort**:
851
+ 失败只留痕,第③步已保证链接可读);
852
+ ⑤ `POST /drive/v1/metas/batch_query`(body 带 `with_url=true`)取规范链接
853
+ ——漏 `with_url` 会 `code=0` 静默回空串,此时按租户域名兜底拼链接。
854
+ """
855
+ body = _rest("POST", "/open-apis/docx/v1/documents", json_body={"title": title},
856
+ cfg=cfg, timeout=timeout) or {}
857
+ doc = (((body.get("data") or {}).get("document") or {}).get("document_id") or "")
858
+ if not doc:
859
+ raise FeishuRestError("建文档成功但没拿到 document_id")
860
+ blocks = _md_blocks(md_text)
861
+ for i in range(0, len(blocks), 50):
862
+ _rest("POST", f"/open-apis/docx/v1/documents/{doc}/blocks/{doc}/children",
863
+ json_body={"children": blocks[i:i + 50], "index": i},
864
+ cfg=cfg, timeout=timeout)
865
+ _rest("PATCH", f"/open-apis/drive/v1/permissions/{doc}/public",
866
+ params={"type": "docx"},
867
+ json_body={"link_share_entity": "tenant_readable",
868
+ "external_access_entity": "open"}, cfg=cfg, timeout=timeout)
869
+ if open_id:
870
+ try:
871
+ # need_notification=false:文档链接已在卡上,再加一条"分享给你"的飞书
872
+ # 通知就是重复打扰(提问多时尤甚);membership 只是链接分享被组织策略
873
+ # 限制时的兜底通道。
874
+ _rest("POST", f"/open-apis/drive/v1/permissions/{doc}/members",
875
+ params={"type": "docx", "need_notification": "false"},
876
+ json_body={"member_type": "openid", "member_id": open_id,
877
+ "perm": "full_access"}, cfg=cfg, timeout=timeout)
878
+ except FeishuRestError as e:
879
+ print(f"feishu docx: 加协作者失败(第③步链接分享已可读,继续): {e!r}",
880
+ file=sys.stderr, flush=True)
881
+ meta = _rest("POST", "/open-apis/drive/v1/metas/batch_query",
882
+ params={"user_id_type": "open_id"},
883
+ json_body={"request_docs": [{"doc_token": doc, "doc_type": "docx"}],
884
+ "with_url": True}, cfg=cfg, timeout=timeout) or {}
885
+ metas = (meta.get("data") or {}).get("metas") or []
886
+ url = (metas[0].get("url") if metas else "") or ""
887
+ return url or f"https://feishu.cn/docx/{doc}" # 兜底:域名由飞书 301/302 重定向
888
+
889
+
890
+ def _plan_doc_get(key):
891
+ """取该 (卡, 提问) 已建文档链接(顺带清过期记录,防无界增长)。"""
892
+ now = time.time()
893
+ for k in [k for k, v in _PLAN_DOCS.items() if now - v.get("at", 0) > _PLAN_DOC_TTL]:
894
+ _PLAN_DOCS.pop(k, None)
895
+ v = _PLAN_DOCS.get(key)
896
+ return v.get("url", "") if v else ""
897
+
898
+
899
+ def _plan_doc_put(key, url):
900
+ """记账(同一提问只建一次文档;上限截断防无界增长)。"""
901
+ _PLAN_DOCS[key] = {"url": url, "at": time.time()}
902
+ while len(_PLAN_DOCS) > 128:
903
+ _PLAN_DOCS.pop(min(_PLAN_DOCS, key=lambda k: _PLAN_DOCS[k]["at"]), None)
904
+
905
+
906
+ def _attach_plan(ctx, project_name, cfg, open_id=""):
907
+ """把「方案」挂到卡片上下文:`plan_inline`(卡内折叠摘要)+ `plan_doc_url`(文档链接)。
908
+
909
+ best-effort:**绝不外抛**——方案是锦上添花,卡本身必须发出去(历史上 form 元素
910
+ 超限曾整卡静默丢失)。无会话(取不到方案)直接返回,不发无意义文档。
911
+ 同一 (卡, 提问) 只建一次文档:调和器理论上一提问只调一次 `card_blocked`,此处是
912
+ 重放/重试保险,且让重放复用同一链接。
913
+ """
914
+ try:
915
+ sid = str(ctx.get("session_id") or "").strip()
916
+ if not sid:
917
+ return
918
+ texts = _plan_session_texts(sid)
919
+ inline = _plan_inline_markdown(ctx, texts)
920
+ if inline:
921
+ ctx["plan_inline"] = inline
922
+ key = (int(ctx.get("card_id") or 0), str(ctx.get("qid") or ""))
923
+ cached = _plan_doc_get(key)
924
+ if cached:
925
+ ctx["plan_doc_url"] = cached
926
+ return
927
+ md = _plan_doc_markdown(ctx, texts, project_name)
928
+ if not md.strip():
929
+ return
930
+ url = _docx_create(_plan_doc_title(ctx), md, cfg, open_id)
931
+ if url:
932
+ _plan_doc_put(key, url)
933
+ ctx["plan_doc_url"] = url
934
+ except Exception as e:
935
+ print(f"feishu docx: 建方案文档失败(卡照发,仅卡内摘要): {e!r}",
936
+ file=sys.stderr, flush=True)
937
+
938
+
517
939
  def _dm_interaction_card(project_id, ctx):
518
- """旁路:向项目所有者的飞书单聊发交互卡片(绑定 + 应用凭据齐全才发)。
940
+ """旁路:向项目所有者的飞书单聊发交互卡片(**受 `notify_events` 闸门约束**,
941
+ 再要求账号绑定 + 应用凭据齐全才发)。
942
+
943
+ 2026-10-10 修实障:此前这里只看绑定与凭据 ⇒ 用户把项目「启用推送」与全部
944
+ 事件取消勾选(甚至关掉用户级总开关)后,单聊卡片仍持续送达,设置形同虚设。
945
+ 现在项目的 `blocked_interaction` 未开即直接不发(与群 webhook 同一闸门)。
519
946
  卡片是即时交互载体,不走 outbox(过期提问的分钟级重试无意义);失败仅
520
947
  留痕——群 webhook 文本推送(push_event)始终是主通道,不受影响。"""
521
948
  try:
949
+ if "blocked_interaction" not in notify_events(project_id):
950
+ return
522
951
  proj = db.get_project(project_id)
523
952
  owner = (proj["user_id"] if proj else 0) or 0
524
953
  binding = db.get_feishu_binding_by_user(owner)
@@ -526,6 +955,10 @@ def _dm_interaction_card(project_id, ctx):
526
955
  if not binding or not cfg:
527
956
  return
528
957
  name = proj["name"] if proj else f"#{project_id}"
958
+ # M6:把 agent 的方案/回答挂到卡上(卡内折叠摘要 + 完整方案飞书文档链接)。
959
+ # 审批卡不做(它的"背景"由审批请求本身给出,建文档没意义);开关可回滚。
960
+ if plan_enabled() and str(ctx.get("kind") or "") != "approval":
961
+ _attach_plan(ctx, name, cfg, binding["open_id"] or "")
529
962
  rest_send_card(binding["open_id"],
530
963
  _build_interaction_card(ctx, name), cfg)
531
964
  except Exception as e:
@@ -548,6 +981,10 @@ def card_blocked(project_id, card, interaction):
548
981
  ctx = {
549
982
  "card_id": card["id"], "title": card.get("title") or "",
550
983
  "kind": kind,
984
+ # M6:方案取数(`_attach_plan` 读会话转录)与建文档去重都要这两个键;
985
+ # 缺了它们 M6 整块自动跳过(旧调用面/测试替身不受影响)。
986
+ "session_id": str(card.get("session_id") or ""),
987
+ "qid": str(interaction.get("qid") or ""),
551
988
  "question": interaction.get("question") or "",
552
989
  # 选项展示名取 label(交互归一后的字段,见 board._iw_interaction 的
553
990
  # dsh 分支;旧代码取不存在的 text 字段,推送里选项序号后恒空白)
@@ -655,8 +1092,9 @@ def _tenant_token(cfg):
655
1092
  return out["tenant_access_token"]
656
1093
 
657
1094
 
658
- def _rest(method, path, *, params=None, json_body=None, cfg=None):
1095
+ def _rest(method, path, *, params=None, json_body=None, cfg=None, timeout=10):
659
1096
  """飞书 REST 统一调用(按用户应用 cfg 鉴权):非 0 业务码抛 FeishuRestError。
1097
+ timeout 可按调用收敛(M6 建方案文档跑在 board 调和线程里,逐调用上限 8s)。
660
1098
  路径规范化:调用方带/不带 /open-apis 前缀均恰好拼出一层(曾因调用方自带前缀
661
1099
  + 此处再拼一层,请求打到 /open-apis/open-apis/... 恒 404 纯文本,且异常被吞
662
1100
  表现为「机器人毫无反应」)。
@@ -673,7 +1111,7 @@ def _rest(method, path, *, params=None, json_body=None, cfg=None):
673
1111
  try:
674
1112
  r = requests.request(
675
1113
  method, url, params=params,
676
- json=json_body, timeout=10,
1114
+ json=json_body, timeout=timeout,
677
1115
  headers={"Authorization": f"Bearer {_tenant_token(cfg)}",
678
1116
  "Content-Type": "application/json"})
679
1117
  except (OSError, ValueError) as e:
@@ -755,12 +1193,19 @@ def bot_open_id(cfg):
755
1193
 
756
1194
  SLASH_API = "/open-apis/application/v7/app_slash_commands"
757
1195
  # (指令名, 中文说明, icon_key):指令名与 _INTENT_RULES 的 /别名 一一对应(顺序即面板顺序);
758
- # icon_key 取值见官方「Slash Command Icon Key 说明」,传错报 40000031
1196
+ # icon_key **必须**取 SLASH_ICON_KEYS(官方「Slash Command Icon Key 说明」全表)内的值,
1197
+ # 表外的键(含按语义自造的)创建即报 40000031
759
1198
  SLASH_COMMANDS = (
760
1199
  ("help", "查看可用指令", "skill_outlined"),
761
1200
  ("bind", "绑定站点账号:/bind 绑定码", "member_outlined"),
762
1201
  ("unbind", "解除站点账号绑定", "clear_outlined"),
763
1202
  ("project", "设置默认项目:/project 项目名", "home_outlined"),
1203
+ ("current", "查看默认项目与当前会话", "home_outlined"),
1204
+ ("projects", "项目总览卡:点选设为默认项目", "database_outlined"),
1205
+ ("sessions", "默认项目最近会话:点选绑定", "chat_outlined"),
1206
+ ("new", "在默认项目下新建会话并绑定:/new [标题]", "add-chat-ai_outlined"),
1207
+ ("use", "绑定已有会话:/use 序号|关键字|会话id前缀", "global-link_outlined"),
1208
+ ("leave", "解绑当前会话(保留默认项目)", "clear_outlined"),
764
1209
  ("status", "查看任务与看板摘要:/status [项目名]", "database_outlined"),
765
1210
  ("cards", "查找卡片:/cards 关键字(id 前缀 / 标题包含)", "codeblock_outlined"),
766
1211
  ("approve", "通过待审核卡片:/approve 卡片", "flag_outlined"),
@@ -774,6 +1219,84 @@ _SLASH_NAMES = tuple(c for c, _, _ in SLASH_COMMANDS)
774
1219
  _SLASH_PERM_CODES = (99991640, 99991672, 99991679)
775
1220
  _SLASH_PERM_HINT = ("应用缺少「应用指令」权限:飞书开发者后台 → 权限管理,添加 "
776
1221
  "application:app_slash_command:read 与 write,创建并发布新版本后重试")
1222
+ _SLASH_ICON_HINT = "指令图标 icon_key 不合法(取值见官方 Slash Command Icon Key 说明)"
1223
+ # 官方「Slash Command Icon Key 说明」全量可选值(2026-10-09 抄自开放平台文档
1224
+ # https://open.feishu.cn/document/mcp_open_tools/agent-best-practices/agent-supports-slash-commands
1225
+ # 的同名章节,共 72 个;缺省 skill_outlined)。**表外的键一律非法**,创建/更新报业务码
1226
+ # 40000031——2026-10-09 真机取证:凭语义自造的 add_outlined / link_outlined 正是这样让
1227
+ # 同步停在 15/17 条(设置页红字即 _SLASH_ICON_HINT)。改图标前先在本表内选,
1228
+ # `tests/test_feishu_slash.py::test_slash_icons_are_official_keys` 会拦住表外的值。
1229
+ SLASH_ICON_KEYS = frozenset((
1230
+ # AI 系(官方表前 31 项)
1231
+ "skill_outlined", "light-ai_outlined", "chat-ai_outlined",
1232
+ "ai-edit-continue_outlined", "education-ai_outlined", "ai-trans-switch_outlined",
1233
+ "language-ai_outlined", "toolbar-more-ai_outlined", "image-ai_outlined",
1234
+ "ai-agent_outlined", "ai-deepthink_outlined", "ai-style_outlined",
1235
+ "ai-reminder_outlined", "ai-block_outlined", "ai-simplify-expression_outlined",
1236
+ "promptword_outlined", "meeting-ai_outlined", "ai-improvewriting_outlined",
1237
+ "slash-ai_outlined", "pa-cost-ai_outlined", "update-ai_outlined",
1238
+ "diagnosis-ai_outlined", "ai-functions_outlined", "mic-ai_outlined",
1239
+ "search-ai_outlined", "explanation-ai_outlined", "add-ai_outlined",
1240
+ "add-chat-ai_outlined", "ai-notification_outlined", "dynamic-layout_outlined",
1241
+ "ai-doc_outlined",
1242
+ # 通用(官方表其余 41 项)
1243
+ "home_outlined", "mail_outlined", "browser-mac_outlined", "flag_outlined",
1244
+ "calendar-line_outlined", "sent_outlined", "gift_outlined", "tag_outlined",
1245
+ "cloud_outlined", "bonus-payroll_outlined", "vote_outlined", "chat_outlined",
1246
+ "emoji_outlined", "member_outlined", "robot_outlined", "mic_outlined",
1247
+ "raisehand_outlined", "officephone_outlined", "effects_outlined", "labs_outlined",
1248
+ "computer_outlined", "clear_outlined", "mouse_outlined", "cellphone_outlined",
1249
+ "cursor_outlined", "plugin_outlined", "edit_outlined", "style-fillcolor_outlined",
1250
+ "codeblock_outlined", "code_outlined", "folder_outlined", "file-link-word_outlined",
1251
+ "file-link-sheet_outlined", "file-link-image_outlined", "global-link_outlined",
1252
+ "champion_outlined", "local_outlined", "day_outlined", "waiting_outlined",
1253
+ "database_outlined", "marketplace_outlined",
1254
+ ))
1255
+
1256
+
1257
+ # ---------- M5 配置自动化:清单真源 / 事件打点 / 环境总闸 ----------
1258
+ # 2026-10-10:新用户「扫码一键接入 + 配置补齐 + 自检」批次(设计见
1259
+ # doc_ai/plan/202610/20261010_1700_飞书配置自动化(扫码一键接入与配置自检).md)。
1260
+ # 下列清单是**唯一真源**:doctor 的逐项检查、register_app 的 addons 预置、
1261
+ # application/v7 config 的补齐三处共用同一份(改这里即可,勿在前端另抄一份)。
1262
+ PROVISION_ENV = "TS_FEISHU_PROVISION" # =0 关闭全部写操作(doctor/status 只读不受影响)
1263
+ REQUIRED_SCOPES = (
1264
+ ("im:message.p2p_msg:readonly", "tenant", "接收单聊消息"),
1265
+ ("im:message.group_at_msg:readonly", "tenant", "接收群聊 @机器人 消息"),
1266
+ ("im:message:send_as_bot", "tenant", "以机器人身份发消息/卡片"),
1267
+ ("application:app_slash_command:read", "tenant", "读取斜杠指令"),
1268
+ ("application:app_slash_command:write", "tenant", "注册斜杠指令"),
1269
+ )
1270
+ REQUIRED_EVENTS = (("im.message.receive_v1", "接收消息"),)
1271
+ REQUIRED_CALLBACKS = (("card.action.trigger", "卡片按钮回调"),)
1272
+ EVENT_FRESH_S = 300 # doctor 判「事件订阅真生效」的新鲜窗口(秒)
1273
+ SEND_PROBE_MIN_S = 60 # send_probe 每用户频控(秒),防连点刷屏
1274
+ PROVISION_TTL = 600 # 扫码流程状态存活上限(秒),过期视为 idle
1275
+
1276
+ # 应用配置/发布端点(官方 application/v7;能力开关在 ability 子路径)
1277
+ _APP_ABILITY_API = "/open-apis/application/v7/applications/%s/ability"
1278
+ _APP_CONFIG_API = "/open-apis/application/v7/applications/%s/config"
1279
+ _APP_PUBLISH_API = "/open-apis/application/v7/applications/%s/publish"
1280
+ _APP_VERSIONS_API = "/open-apis/application/v6/applications/%s/app_versions"
1281
+ # 补齐应用配置所需的权限(冷启动点:扫码新建的应用默认没有它,需控制台加一次或管理员免审)
1282
+ _PATCH_SCOPE = "application:application:patch"
1283
+ _PATCH_SCOPE_HINT = (f"应用缺少「{_PATCH_SCOPE}」权限:请到开发者后台 → 权限管理添加该权限"
1284
+ "并创建版本发布;或把该权限对应用设为免审后再试")
1285
+
1286
+ _LAST_EVENT_TS = {} # user_id -> 最近一次收到飞书事件(消息/卡片回调)的时刻
1287
+
1288
+
1289
+ def _touch_event(user_id):
1290
+ """事件到达打点:入站长连接的 handler 入口调用(消息与卡片回调都算)。
1291
+ doctor 的「事件订阅是否真生效」只能靠真实事件证明(订阅方式配错时连接照样
1292
+ 建得起来),此时间戳即唯一证据;仅进程内存态,重启归零。"""
1293
+ _LAST_EVENT_TS[user_id] = time.time()
1294
+
1295
+
1296
+ def provision_enabled():
1297
+ """配置自动化写操作(扫码建应用/补齐配置/提交发布)总闸:TS_FEISHU_PROVISION=0 关闭。
1298
+ 只读的 status/doctor 不受影响——关掉后仍可体检,只是不能改用户应用配置。"""
1299
+ return (os.environ.get(PROVISION_ENV) or "1") != "0"
777
1300
 
778
1301
 
779
1302
  def _slash_description(desc):
@@ -814,7 +1337,7 @@ def slash_error_hint(err):
814
1337
  if code in _SLASH_PERM_CODES:
815
1338
  return _SLASH_PERM_HINT
816
1339
  if code == 40000031:
817
- return "指令图标 icon_key 不合法(取值见官方 Slash Command Icon Key 说明)"
1340
+ return _SLASH_ICON_HINT
818
1341
  if code == 99992402:
819
1342
  return "指令参数校验失败:指令名需为不含「/」的标识符,说明不可为空"
820
1343
  return str(err)
@@ -924,6 +1447,11 @@ def sync_slash_commands(user_id):
924
1447
  failed = []
925
1448
  need_scope = False
926
1449
  for name, desc, icon in SLASH_COMMANDS:
1450
+ if icon not in SLASH_ICON_KEYS:
1451
+ # 本地预检:表外的键飞书必回 40000031,先在此拦下——省一次请求,
1452
+ # 且不让远端停在「同步到一半」的状态(同步是逐条进行的)
1453
+ failed.append(f"{name}: {_SLASH_ICON_HINT}")
1454
+ continue
927
1455
  try:
928
1456
  buckets[_slash_upsert(cfg, name, desc, icon, by_name)].append(name)
929
1457
  except FeishuRestError as e:
@@ -1050,9 +1578,16 @@ def _on_slash_perm_action(sender, value, cfg):
1050
1578
 
1051
1579
  HELP_TEXT = (
1052
1580
  "Touchstone 指令(单聊可用):\n"
1581
+ "直接发普通文字 = 与当前会话对话(未绑定会在默认项目自动新建)。\n"
1053
1582
  "· 帮助 — 本说明\n"
1054
1583
  "· 绑定 <码> — 绑定站点账号(绑定码在站点侧栏「设置」→ 飞书设置 生成)\n"
1055
1584
  "· 解绑 — 解除绑定\n"
1585
+ "· 当前 — 查看默认项目与当前会话\n"
1586
+ "· 项目 — 项目总览卡(点选即设为默认项目)\n"
1587
+ "· 会话 — 默认项目最近活跃的 10 个会话(点选即绑定)\n"
1588
+ "· 新建会话 [标题] — 在默认项目下新建并绑定\n"
1589
+ "· 绑定会话 <序号|标题关键字|会话id前缀> — 绑定已有会话\n"
1590
+ "· 解绑会话 — 只解绑会话,保留默认项目\n"
1056
1591
  "· 默认项目 <项目名> — 设置默认项目\n"
1057
1592
  "· 状态 [项目名] — 任务与看板摘要\n"
1058
1593
  "· 卡片 <关键字> — 找卡片(id 前缀/标题包含)\n"
@@ -1196,11 +1731,13 @@ def _ws_run(user_id, cfg):
1196
1731
  return # 依赖未装:入站禁用(M1 出站 webhook 不受影响)
1197
1732
 
1198
1733
  def _handler(data):
1734
+ _touch_event(user_id) # 事件到达打点(doctor 判「订阅是否真生效」)
1199
1735
  _on_message_event(data, cfg)
1200
1736
 
1201
1737
  def _on_card_sdk(data):
1202
1738
  # 返回值(toast)由 ws 客户端序列化回响应帧——丢弃则点击反馈(toast)
1203
1739
  # 恒不生效(SDK 路径此前即如此)
1740
+ _touch_event(user_id)
1204
1741
  return _on_card_action(_card_sdk_to_dict(data), cfg)
1205
1742
 
1206
1743
  handler = (lark.EventDispatcherHandler.builder("", "")
@@ -1259,7 +1796,9 @@ def _seen_message(mid):
1259
1796
  def handle_message_event(evt, cfg=None):
1260
1797
  """入站消息主流程(M2 规则指令全为快路径,内联同步执行;M3 agent 解析需挪线程):
1261
1798
  去重 → 群聊 @ 判定(未 @ 静默)→ 文本抽取(剥 @占位符)→ 绑定校验(未绑引导)
1262
- → 意图解析(未识别回帮助)→ 执行 → reply。任何异常吞掉。
1799
+ → 意图解析 → 执行 → reply。**未识别文本**不是一律回帮助:单聊且通用对话开启
1800
+ (`feishu_conv.enabled()`)时走 `feishu_conv.route_text` 投递给绑定的会话;群聊
1801
+ 与 `TS_FEISHU_CONV=0` 才回 `HELP_TEXT`(feishu.py:1337-1343)。任何异常吞掉。
1263
1802
  cfg = 连接所有者(收到消息的应用)的凭据,供回复/群聊判定使用。"""
1264
1803
  try:
1265
1804
  mid = evt.get("message_id")
@@ -1307,9 +1846,14 @@ def handle_message_event(evt, cfg=None):
1307
1846
  "生成绑定码后在这里回复「绑定 <码>」完成绑定", cfg)
1308
1847
  return
1309
1848
  if intent is None:
1310
- rest_reply(mid, HELP_TEXT, cfg)
1849
+ if evt.get("chat_type") != "group" and feishu_conv.enabled():
1850
+ # 单聊未识别文本 → 通用对话路由(投递给当前会话;无会话按默认项目自动新建)
1851
+ reply = feishu_conv.route_text(binding, text, cfg)
1852
+ else:
1853
+ reply = HELP_TEXT # 群聊保持原行为:不落会话
1854
+ rest_reply(mid, reply, cfg)
1311
1855
  return
1312
- reply = execute_intent(binding, intent, sender)
1856
+ reply = execute_intent(binding, intent, sender, cfg)
1313
1857
  if reply:
1314
1858
  rest_reply(mid, reply, cfg)
1315
1859
  except Exception as e:
@@ -1341,7 +1885,8 @@ def _on_card_action(data, cfg):
1341
1885
  """卡片点击回调主流程:event_id 去重 → open_id 绑定校验(未绑 DM 引导)→
1342
1886
  动作分发(t=a 单题选项作答 / mq 多选类点选(多子题逐题点选、单题多选
1343
1887
  mini 表单)/ p 批准 / s 会话内批准 /
1344
- d 拒绝 / sc 快捷指令权限卡片「我已开通,重试注册」)→ 与文本指令共用的
1888
+ d 拒绝 / sc 快捷指令权限卡片「我已开通,重试注册」/
1889
+ dp 项目总览卡点选设默认项目 / ss 会话列表卡点选绑定会话)→ 与文本指令共用的
1345
1890
  _answer_by_token / _answer_multi_click / _decide_approval → DM 文本完整回执 + 返回 toast。
1346
1891
  value 精简键:t 动作(a 单题作答 / mq 多选类点选 / p 批准 / s 会话内批准 /
1347
1892
  d 拒绝)、c 卡号、n 题号(多题点选用)、i 选项序号(序号在执行器里重读服务端
@@ -1363,6 +1908,15 @@ def _on_card_action(data, cfg):
1363
1908
  # M4 权限引导卡片(t=sc)独立于卡片交互流:不带卡号,回调里重跑斜杠指令同步
1364
1909
  if t == "sc":
1365
1910
  return _on_slash_perm_action(sender, value, cfg)
1911
+ # 通用对话卡片(t=dp 项目总览 / t=ss 会话列表)同样不带卡号:在既有看板
1912
+ # 卡片白名单**之前**分流,看板回调(a/mq/p/s/d)路径一个字节都不动
1913
+ if t in ("dp", "ss"):
1914
+ binding = db.get_feishu_binding_by_open(sender)
1915
+ if binding is None:
1916
+ _dm_text(sender, "尚未绑定 Touchstone 账号:登录站点 → 侧栏「设置」→ "
1917
+ "飞书设置,生成绑定码后在单聊回复「绑定 <码>」完成绑定", cfg)
1918
+ return {"toast": {"type": "error", "content": "尚未绑定站点账号"}}
1919
+ return feishu_conv.on_card_action(sender, binding, value, action, cfg)
1366
1920
  if t not in ("a", "mq", "p", "s", "d") or not key:
1367
1921
  return None
1368
1922
  binding = db.get_feishu_binding_by_open(sender)
@@ -1433,6 +1987,12 @@ _INTENT_RULES = (
1433
1987
  ("bind", r"^(?:绑定|/bind)\s*([A-Za-z0-9]{4,8})$"),
1434
1988
  ("unbind", r"^(?:解绑|/unbind)$"),
1435
1989
  ("default_project", r"^(?:默认项目|/project)\s+(\S+)$"),
1990
+ ("current", r"^(?:当前|/current)$"),
1991
+ ("projects", r"^(?:项目|/projects)$"),
1992
+ ("sessions", r"^(?:会话|/sessions)$"),
1993
+ ("new", r"^(?:新建会话|/new)(?:\s+(.+))?$"),
1994
+ ("use", r"^(?:绑定会话|/use)\s+(.+)$"),
1995
+ ("leave", r"^(?:解绑会话|/leave)$"),
1436
1996
  ("status", r"^(?:状态|/status)(?:\s+(.+))?$"),
1437
1997
  ("card", r"^(?:卡片|/cards?)\s+(.+)$"),
1438
1998
  ("approve", r"^(?:通过|/approve)\s+(\S+)$"),
@@ -1475,9 +2035,10 @@ def bind_user(code, open_id):
1475
2035
  return None
1476
2036
 
1477
2037
 
1478
- def execute_intent(binding, intent, sender_open_id):
2038
+ def execute_intent(binding, intent, sender_open_id, cfg=None):
1479
2039
  """绑定用户身份执行意图;返回回复文案(空串=不回复)。项目/卡片访问一律先过
1480
- 归属(list_projects(user_id) 限用户项目),异常统一转友好文案。"""
2040
+ 归属(list_projects(user_id) 限用户项目),异常统一转友好文案。
2041
+ cfg = 本次消息所属应用的凭据(转交通用对话层做卡片/回执)。"""
1481
2042
  action, g = intent["action"], intent["groups"]
1482
2043
  try:
1483
2044
  if action == "help":
@@ -1489,6 +2050,8 @@ def execute_intent(binding, intent, sender_open_id):
1489
2050
  return "已解绑。重新绑定请到站点生成新的绑定码"
1490
2051
  if action == "default_project":
1491
2052
  return _set_default_project(binding, g[0] or "")
2053
+ if action in ("current", "projects", "sessions", "new", "use", "leave"):
2054
+ return feishu_conv.conv_intent(binding, action, g, sender_open_id, cfg)
1492
2055
  return _execute_board_action(binding, intent, sender_open_id)
1493
2056
  except FeishuRestError:
1494
2057
  return "飞书接口调用失败,请稍后重试"
@@ -1496,18 +2059,23 @@ def execute_intent(binding, intent, sender_open_id):
1496
2059
  return "指令执行失败,请稍后重试"
1497
2060
 
1498
2061
 
1499
- def _set_default_project(binding, name):
1500
- """设置单聊默认项目:精确名 → 唯一包含匹配;否则报项目不在列表。"""
2062
+ def _set_default_project(binding, name, project_id=0):
2063
+ """设置单聊默认项目:`project_id` 非 0 时**按 id** 设(卡片点选 `t=dp` 路径),
2064
+ 否则按名匹配(精确名 → 唯一包含匹配)。两条路径共用
2065
+ `feishu_conv.set_default_project`,换项目时自动清当前会话(设计 §3.2)。"""
2066
+ if project_id:
2067
+ project = feishu_conv._project_by_id(binding, project_id)
2068
+ if project is None:
2069
+ return "项目不在你的项目列表"
2070
+ return feishu_conv.set_default_project(binding, project)
1501
2071
  user_id = binding["user_id"]
1502
2072
  projects = db.list_projects(user_id)
1503
2073
  for p in projects:
1504
2074
  if p["name"] == name:
1505
- db.set_feishu_default_project(binding["open_id"], p["id"])
1506
- return f"默认项目已设为「{name}」"
2075
+ return feishu_conv.set_default_project(binding, p)
1507
2076
  hits = [p for p in projects if name in p["name"]]
1508
2077
  if len(hits) == 1:
1509
- db.set_feishu_default_project(binding["open_id"], hits[0]["id"])
1510
- return f"默认项目已设为「{hits[0]['name']}」"
2078
+ return feishu_conv.set_default_project(binding, hits[0])
1511
2079
  return f"项目「{name}」不在你的项目列表"
1512
2080
 
1513
2081
 
@@ -1654,8 +2222,8 @@ def _answer_multi_click(p, c, value, action):
1654
2222
  f"请回复「同意 {c['id']}」或「拒绝 {c['id']}」",
1655
2223
  _toast("error", "该等待是审批"))
1656
2224
  if not st.get("answerable"):
1657
- return ("该等待不支持远程作答,请到站点会话窗口处理",
1658
- _toast("error", "不支持远程作答"))
2225
+ return (_NO_REMOTE_ANSWER_HINT,
2226
+ _toast("error", "请到 dsh 会话窗口作答"))
1659
2227
  qs = [q for q in (st.get("questions") or []) if q]
1660
2228
  n = len(qs)
1661
2229
  if n < 1:
@@ -1803,7 +2371,7 @@ def _answer_by_token(p, c, token):
1803
2371
  # 审批等待收到「作答」:引导用同意/拒绝指令(answer_interaction 只收提问)
1804
2372
  return f"该等待是工具审批,请回复「同意 {c['id']}」或「拒绝 {c['id']}」"
1805
2373
  if not st.get("answerable"):
1806
- return "该等待不支持远程作答,请到站点会话窗口处理"
2374
+ return _NO_REMOTE_ANSWER_HINT
1807
2375
  # 作答须一次给全全部子题(answers 逐题 record,漏项即未答):多子题走
1808
2376
  # 「题号:值」逐题形态(2026-10-06 第二档),并与卡片点选暂存**合并**
1809
2377
  # (2026-10-07 第三档)——文本只需给没点过的题,缺题仍不提交
@@ -1925,6 +2493,13 @@ def _execute_board_action(binding, intent, sender_open_id):
1925
2493
  return f"卡片 #{c['id']} 不在「待审核」列(当前 {c['column_key']}),无法通过"
1926
2494
  _, err = board.move_card(p, c["id"], "done")
1927
2495
  if err:
2496
+ if "merge_pending" in err:
2497
+ # 独立 worktree 卡的待合并闸(2026-10-07 批次):飞书侧没有弹框,
2498
+ # 不替用户选「交给 agent 合并 / 仅通过」,引导去网页看板操作
2499
+ mp = err.get("merge_pending") or {}
2500
+ return (f"卡片 #{c['id']} 还有 {mp.get('ahead', 0)} 个提交没回流主分支"
2501
+ f"({mp.get('branch', '')} → {mp.get('target', '')});"
2502
+ f"请在网页看板上点「通过」,选择「交给 agent 合并」或「仅通过(不合并)」")
1928
2503
  return "审核失败:" + str(err.get("error") or err.get("blocked") or "门禁拦截")
1929
2504
  return f"✅ 卡片 #{c['id']} {(c['title'] or '')[:30]} 已审核通过"
1930
2505
  if action == "reject":
@@ -2088,20 +2663,546 @@ def _send_loop():
2088
2663
 
2089
2664
 
2090
2665
  def start_notifier():
2091
- """幂等启动投递线程(server main 启动时调用一次;未配置时空转无害)。"""
2666
+ """幂等启动投递线程(server main 启动时调用一次;未配置时空转无害)。
2667
+
2668
+ 同时注册通用对话的消息终态钩子(feishu_conv.start,幂等):答复回流靠它把
2669
+ 「跑完的会话消息」推回飞书 DM——这是本模块唯一的接线点(server.main 已调用
2670
+ 本函数),漏掉则回流全哑且无任何报错。
2671
+ """
2092
2672
  global _SENDER_STARTED
2093
2673
  if _SENDER_STARTED:
2094
2674
  return
2095
2675
  _SENDER_STARTED = True
2676
+ feishu_conv.start() # 幂等:注册 chat 的消息终态钩子
2096
2677
  threading.Thread(target=_send_loop, daemon=True,
2097
2678
  name="feishu-notifier").start()
2098
2679
 
2099
2680
 
2100
2681
  # ---------- CLI(自测通道) ----------
2101
2682
 
2683
+ # ---------- M5 配置自动化:凭据快检 / 应用配置补齐 ----------
2684
+
2685
+ def verify_credentials(user_id):
2686
+ """凭据快检(保存凭据后的即时反馈):token 可取 → 机器人信息可读,两步。
2687
+
2688
+ 返回 {"ok": bool, "detail": str},**绝不抛出**;无凭据时零请求。
2689
+ 今天保存凭据只写库不校验,错了只能翻日志——本函数是那个缺口的补口。"""
2690
+ cfg = app_config(user_id)
2691
+ if cfg is None:
2692
+ return {"ok": False, "detail": "应用凭据未配置(App ID / App Secret)"}
2693
+ try:
2694
+ _tenant_token(cfg) # 第一步:凭据正确性(失败码 10003/10014 等)
2695
+ out = _rest("GET", "/open-apis/bot/v3/info", cfg=cfg)
2696
+ # /bot/v3/info 的 bot 在响应**顶层**(无 data 包裹,同 bot_open_id 的读法)
2697
+ bot = out.get("bot") or {}
2698
+ name = bot.get("app_name") or "(未命名)"
2699
+ act = bot.get("activate_status")
2700
+ tail = "已启用" if act == 2 else f"启用状态={act}(可能未发布/未启用)"
2701
+ return {"ok": True, "detail": f"凭据可用:机器人 {name}({tail})"}
2702
+ except FeishuRestError as e:
2703
+ return {"ok": False, "detail": str(e)}
2704
+ except Exception as e: # 兜底:网络/形态异常一律转文案,不抛到 HTTP 层
2705
+ return {"ok": False, "detail": f"探测失败: {e!r}"}
2706
+
2707
+
2708
+ def _apply_error(e):
2709
+ """补齐失败的文案:缺 patch 权限给精确开通引导,其余原样透出。"""
2710
+ if _need_scope(e):
2711
+ return _PATCH_SCOPE_HINT
2712
+ return str(e)
2713
+
2714
+
2715
+ def provision_apply(user_id):
2716
+ """把平台所需的**机器人能力 + 权限 + 长连接订阅 + 事件 + 回调**一次补齐到
2717
+ 用户自己的应用(只动本用户配置里的 app_id,前端永不传 app_id)。
2718
+
2719
+ 两步:① PATCH .../ability 开机器人能力({bot:{enable:true}});
2720
+ ② PATCH .../config 一次带上 scope.add_scopes / event.subscription_type=websocket
2721
+ + add_events / callback.add_callbacks。失败即停并如实报告(缺
2722
+ application:application:patch 时给开通引导,不静默)。
2723
+
2724
+ 返回 {"ok", "need_patch_scope", "error", "steps":[{key,label,ok,detail}]}。"""
2725
+ if not provision_enabled():
2726
+ return {"ok": False, "need_patch_scope": False, "steps": [],
2727
+ "error": f"配置自动化已关闭({PROVISION_ENV}=0)"}
2728
+ cfg = app_config(user_id)
2729
+ if cfg is None:
2730
+ return {"ok": False, "need_patch_scope": False, "steps": [],
2731
+ "error": "应用凭据未配置(App ID / App Secret),请先保存凭据"}
2732
+ app_id = cfg["app_id"]
2733
+ steps = []
2734
+ # ① 机器人能力(错误码 210041 即「未开启机器人能力」,先开它)
2735
+ try:
2736
+ _rest("PATCH", _APP_ABILITY_API % app_id,
2737
+ json_body={"bot": {"enable": True}}, cfg=cfg)
2738
+ steps.append({"key": "ability", "label": "开启机器人能力",
2739
+ "ok": True, "detail": "已开启"})
2740
+ except FeishuRestError as e:
2741
+ steps.append({"key": "ability", "label": "开启机器人能力",
2742
+ "ok": False, "detail": str(e)})
2743
+ return {"ok": False, "need_patch_scope": _need_scope(e),
2744
+ "error": _apply_error(e), "steps": steps}
2745
+ # ② 权限 + 订阅方式(长连接)+ 事件 + 回调(一次 PATCH,减少请求与半生效窗口)
2746
+ body = {
2747
+ "scope": {"add_scopes": [{"scope_name": n, "token_type": t}
2748
+ for n, t, _ in REQUIRED_SCOPES]},
2749
+ "event": {"subscription_type": "websocket",
2750
+ "add_events": [e for e, _ in REQUIRED_EVENTS]},
2751
+ "callback": {"callback_type": "websocket",
2752
+ "add_callbacks": [c for c, _ in REQUIRED_CALLBACKS]},
2753
+ }
2754
+ try:
2755
+ _rest("PATCH", _APP_CONFIG_API % app_id, json_body=body, cfg=cfg)
2756
+ steps.append({"key": "config", "label": "权限 / 长连接订阅 / 事件 / 回调",
2757
+ "ok": True, "detail": "已写入(需发布新版本后生效)"})
2758
+ except FeishuRestError as e:
2759
+ steps.append({"key": "config", "label": "权限 / 长连接订阅 / 事件 / 回调",
2760
+ "ok": False, "detail": str(e)})
2761
+ return {"ok": False, "need_patch_scope": _need_scope(e),
2762
+ "error": _apply_error(e), "steps": steps}
2763
+ return {"ok": True, "need_patch_scope": False, "error": "", "steps": steps}
2764
+
2765
+
2766
+ # ---------- M5 配置自动化:配置自检(doctor) ----------
2767
+
2768
+ _LAST_PROBE_TS = {} # user_id -> 上次发「测试消息」的时刻(send_probe 频控)
2769
+
2770
+
2771
+ def _doc_item(key, label, state, detail, hint=""):
2772
+ """doctor 单项(state ∈ ok|fail|warn|skip;hint 是中文修复指引)。"""
2773
+ return {"key": key, "label": label, "state": state, "detail": detail, "hint": hint}
2774
+
2775
+
2776
+ def doctor(user_id, send_probe=False):
2777
+ """逐项配置体检(**只读为主**,绝不抛出):把「存没存上、对不对、通不通」一次说清。
2778
+
2779
+ 八项:creds(凭据齐全)/ token(凭据有效)/ bot(机器人能力与启用状态)/
2780
+ inbound_ws(长连接线程在跑)/ inbound_event(**真实收到过事件**——订阅方式配错时
2781
+ 连接照样建得起来,此时间戳是唯一证据)/ slash_scope(斜杠指令权限)/
2782
+ send_scope(发消息权限,send_probe=True 时才真发一条自检 DM,60s 频控)/
2783
+ webhook(默认推送地址已配)。
2784
+
2785
+ 返回 {"items":[{key,label,state,detail,hint}], "summary":{ok,fail,warn,skip}}。"""
2786
+ cfg = dict(user_config(user_id) or {})
2787
+ has_creds = bool(cfg.get("app_id") and cfg.get("app_secret"))
2788
+ items = [_doc_item(
2789
+ "creds", "应用凭据(App ID / App Secret)",
2790
+ "ok" if has_creds else "fail",
2791
+ "已配置" if has_creds else "未配置",
2792
+ "" if has_creds else "在「飞书机器人配置」填入 App ID 与 App Secret 并保存")]
2793
+
2794
+ token_ok = False
2795
+ if not has_creds:
2796
+ items.append(_doc_item("token", "凭据有效性(tenant_access_token)",
2797
+ "skip", "凭据未配置,跳过", ""))
2798
+ else:
2799
+ try:
2800
+ _tenant_token(cfg)
2801
+ token_ok = True
2802
+ items.append(_doc_item("token", "凭据有效性(tenant_access_token)",
2803
+ "ok", "token 获取成功", ""))
2804
+ except FeishuRestError as e:
2805
+ items.append(_doc_item("token", "凭据有效性(tenant_access_token)",
2806
+ "fail", str(e),
2807
+ "核对取值:开发者后台 → 凭证与基础信息 的 App ID / App Secret"))
2808
+ except Exception as e:
2809
+ items.append(_doc_item("token", "凭据有效性(tenant_access_token)",
2810
+ "fail", f"探测失败: {e!r}", "检查站点到 open.feishu.cn 的网络"))
2811
+
2812
+ if not token_ok:
2813
+ items.append(_doc_item("bot", "机器人能力与启用状态",
2814
+ "skip", "凭据未通过,跳过", ""))
2815
+ else:
2816
+ try:
2817
+ out = _rest("GET", "/open-apis/bot/v3/info", cfg=cfg)
2818
+ bot = out.get("bot") or {}
2819
+ act = bot.get("activate_status")
2820
+ name = bot.get("app_name") or "(未命名)"
2821
+ ok = act == 2
2822
+ items.append(_doc_item(
2823
+ "bot", "机器人能力与启用状态", "ok" if ok else "warn",
2824
+ f"{name}(activate_status={act})",
2825
+ "" if ok else "应用能力 → 添加「机器人」,并在版本管理与发布里发布后生效"))
2826
+ except FeishuRestError as e:
2827
+ items.append(_doc_item("bot", "机器人能力与启用状态", "fail", str(e),
2828
+ "应用能力 → 添加应用能力 → 机器人,然后创建版本发布"))
2829
+ except Exception as e:
2830
+ items.append(_doc_item("bot", "机器人能力与启用状态", "fail",
2831
+ f"探测失败: {e!r}", "稍后重试"))
2832
+
2833
+ st = inbound_status(user_id)
2834
+ if not has_creds:
2835
+ items.append(_doc_item("inbound_ws", "入站长连接(长连接)", "skip",
2836
+ "凭据未配置,跳过", ""))
2837
+ else:
2838
+ items.append(_doc_item(
2839
+ "inbound_ws", "入站长连接(长连接)",
2840
+ "ok" if st.get("thread") else "fail",
2841
+ "连接线程运行中" if st.get("thread") else "未运行",
2842
+ "" if st.get("thread") else
2843
+ "凭据保存后自动连接;仍不启动看站点日志,并确认事件订阅方式为「长连接」"))
2844
+
2845
+ last_ev = _LAST_EVENT_TS.get(user_id)
2846
+ if not has_creds:
2847
+ items.append(_doc_item("inbound_event", "事件订阅(真实收到过消息)", "skip",
2848
+ "凭据未配置,跳过", ""))
2849
+ elif last_ev and (time.time() - last_ev) <= EVENT_FRESH_S:
2850
+ items.append(_doc_item("inbound_event", "事件订阅(真实收到过消息)", "ok",
2851
+ f"{int(time.time() - last_ev)} 秒前收到过事件", ""))
2852
+ else:
2853
+ items.append(_doc_item(
2854
+ "inbound_event", "事件订阅(真实收到过消息)", "warn",
2855
+ "尚未收到过事件" if not last_ev else "最近一次事件已超过 5 分钟",
2856
+ "给机器人**发一条消息**验证:开发者后台 → 事件与回调 → 订阅方式选「长连接」"
2857
+ "并添加 im.message.receive_v1(按钮交互还需回调 card.action.trigger)"))
2858
+
2859
+ if not token_ok:
2860
+ items.append(_doc_item("slash_scope", "斜杠指令权限", "skip", "凭据未通过,跳过", ""))
2861
+ else:
2862
+ try:
2863
+ sl = slash_status(user_id)
2864
+ except Exception as e:
2865
+ # slash_status 只吞 FeishuRestError;响应形态意外(如 data 非 dict)会漏出
2866
+ # 其他异常——doctor 承诺绝不外抛,这里降级为该项 fail。
2867
+ items.append(_doc_item("slash_scope", "斜杠指令权限", "fail",
2868
+ f"探测失败: {e!r}",
2869
+ "稍后重试;仍失败可在「快捷指令」卡手动「注册 / 同步」看具体报错"))
2870
+ sl = None
2871
+ if sl is None:
2872
+ pass # 探测异常:已在上面落 fail 项
2873
+ elif sl.get("need_scope"):
2874
+ items.append(_doc_item(
2875
+ "slash_scope", "斜杠指令权限", "fail", sl.get("error") or "缺权限",
2876
+ "权限管理 添加 application:app_slash_command:read 与 write,"
2877
+ "并创建版本发布;开通后回设置页点「注册 / 同步到飞书」"))
2878
+ elif sl.get("error"):
2879
+ items.append(_doc_item("slash_scope", "斜杠指令权限", "warn",
2880
+ str(sl["error"]), "可在设置页「快捷指令」卡重试"))
2881
+ else:
2882
+ items.append(_doc_item("slash_scope", "斜杠指令权限", "ok",
2883
+ "权限可用", ""))
2884
+
2885
+ binding = None
2886
+ try:
2887
+ binding = db.get_feishu_binding_by_user(user_id)
2888
+ except Exception:
2889
+ binding = None
2890
+ if not token_ok or not binding:
2891
+ items.append(_doc_item(
2892
+ "send_scope", "发消息权限(可选探测)", "skip",
2893
+ "未绑定飞书账号,跳过" if not binding else "凭据未通过,跳过",
2894
+ "" if binding else "先在下方生成绑定码并在飞书里发「绑定 <码>」"))
2895
+ elif not send_probe:
2896
+ items.append(_doc_item("send_scope", "发消息权限(可选探测)", "skip",
2897
+ "未探测(勾选「发送测试消息」后重跑自检)", ""))
2898
+ elif (time.time() - (_LAST_PROBE_TS.get(user_id) or 0)) < SEND_PROBE_MIN_S:
2899
+ items.append(_doc_item("send_scope", "发消息权限(可选探测)", "skip",
2900
+ f"{SEND_PROBE_MIN_S} 秒内已发过测试消息,稍后再试", ""))
2901
+ else:
2902
+ try:
2903
+ rest_send_text(binding["open_id"],
2904
+ "🔍 Touchstone 配置自检:收到这条消息说明「发消息」权限已开通。",
2905
+ cfg)
2906
+ _LAST_PROBE_TS[user_id] = time.time()
2907
+ items.append(_doc_item("send_scope", "发消息权限(可选探测)", "ok",
2908
+ "测试消息已发送,去飞书查看", ""))
2909
+ except FeishuRestError as e:
2910
+ items.append(_doc_item("send_scope", "发消息权限(可选探测)", "fail", str(e),
2911
+ "权限管理 添加 im:message:send_as_bot 并创建版本发布"))
2912
+ except Exception as e:
2913
+ items.append(_doc_item("send_scope", "发消息权限(可选探测)", "fail",
2914
+ f"探测失败: {e!r}", "稍后重试"))
2915
+
2916
+ hook = cfg.get("default_webhook") or ""
2917
+ items.append(_doc_item(
2918
+ "webhook", "默认推送 Webhook", "ok" if hook else "warn",
2919
+ "已配置" if hook else "未配置(不影响机器人对话,只影响阻塞事件推送)",
2920
+ "" if hook else "群设置 → 群机器人 → 添加自定义机器人,把 Webhook 填到设置页"))
2921
+
2922
+ summary = {"ok": 0, "fail": 0, "warn": 0, "skip": 0}
2923
+ for it in items:
2924
+ summary[it["state"]] = summary.get(it["state"], 0) + 1
2925
+ return {"items": items, "summary": summary}
2926
+
2927
+
2928
+ # ---------- M5 配置自动化:扫码建应用(Device Flow 状态机) ----------
2929
+
2930
+ _PROVISION = {} # user_id -> {"state","url","error","app_id","steps","started_at","cancel"}
2931
+ _PROVISION_LOCK = threading.Lock() # 「读状态 → 写状态」串行化(并发点两下只建一条流程)
2932
+ _REGISTER_MIN_VERSION = (1, 5, 5) # register_app 的 lark-oapi 下限
2933
+
2934
+
2935
+ def _lark_min_ok():
2936
+ """lark-oapi 版本是否达标——**只读包元数据,不 import SDK**。
2937
+
2938
+ 为什么不 import:`lark_oapi.ws.client` 在模块级调 `asyncio.get_event_loop()`,
2939
+ 在非主线程首次 import 有拿不到事件循环的风险(且拖慢首次调用);真正的 SDK
2940
+ import 只发生在用户点「扫码创建」后的后台线程里(与既有 `_ws_run` 同路径)。"""
2941
+ try:
2942
+ import importlib.metadata as md
2943
+ ver = md.version("lark-oapi")
2944
+ except Exception:
2945
+ return False
2946
+ nums = []
2947
+ for part in ver.split(".")[:3]:
2948
+ digits = ""
2949
+ for ch in part:
2950
+ if not ch.isdigit():
2951
+ break
2952
+ digits += ch
2953
+ nums.append(int(digits or 0))
2954
+ return tuple(nums) >= _REGISTER_MIN_VERSION
2955
+
2956
+
2957
+ def _register_app_impl(on_qr_code, on_status_change, cancel_event, addons):
2958
+ """SDK 调用的**可替换封装**(测试打桩点,也是唯一直接碰 SDK 的地方)。"""
2959
+ if not _lark_min_ok():
2960
+ raise RuntimeError("lark-oapi 版本过低或未安装(register_app 需 ≥1.5.5)")
2961
+ import lark_oapi
2962
+ return lark_oapi.register_app(on_qr_code=on_qr_code,
2963
+ on_status_change=on_status_change,
2964
+ cancel_event=cancel_event, addons=addons)
2965
+
2966
+
2967
+ def provision_supported():
2968
+ """本机是否具备「扫码一键建应用」能力(lark-oapi ≥1.5.5 含 register_app)。"""
2969
+ return _lark_min_ok()
2970
+
2971
+
2972
+ def _provision_addons():
2973
+ """register_app 的 addons 载荷:**创建时**就把平台所需的权限/事件/回调带上。
2974
+
2975
+ 这是绕开 `application:application:patch` 冷启动(新建应用默认没有该权限)
2976
+ 的主路径;preset=False 表示不要官方智能体模板(最小权限,只声明我们自己的清单)。"""
2977
+ return {
2978
+ "preset": False,
2979
+ "scopes": {"tenant": [n for n, t, _ in REQUIRED_SCOPES if t == "tenant"]},
2980
+ "events": {"items": {"tenant": [e for e, _ in REQUIRED_EVENTS]}},
2981
+ "callbacks": {"items": [c for c, _ in REQUIRED_CALLBACKS]},
2982
+ }
2983
+
2984
+
2985
+ def provision_status(user_id):
2986
+ """扫码流程现状(设置页轮询口,只读)。等待超 TTL 即对外按失败呈现。
2987
+
2988
+ `app_id` = 在跑流程的应用优先,否则回落**用户配置里已接入的应用**——前端据此
2989
+ 决定「是否二次确认 + force」,只回进程内状态会让已有应用的用户永远发不出
2990
+ force(扫码换应用死锁;站点重启后连扫码建过应用的用户也会掉进去)。"""
2991
+ st = _PROVISION.get(user_id) or {}
2992
+ state = st.get("state", "idle")
2993
+ error = st.get("error", "")
2994
+ if state == "waiting" and (time.time() - st.get("started_at", 0)) > PROVISION_TTL:
2995
+ state = "failed"
2996
+ error = error or "确认链接已超时(10 分钟有效),可重新发起"
2997
+ # 回落取**配置里的 app_id**(不要求 secret 齐——只填了 App ID 也算「已接入」,
2998
+ # 前端据此二次确认;真正的 force 闸门仍按 app_config(两件齐全)判定)
2999
+ app_id = st.get("app_id") or ((user_config(user_id) or {}).get("app_id") or "")
3000
+ return {"supported": provision_supported(), "enabled": provision_enabled(),
3001
+ "state": state, "url": st.get("url", ""), "error": error,
3002
+ "app_id": app_id, "steps": st.get("steps", []),
3003
+ "age_s": int(time.time() - st["started_at"]) if st.get("started_at") else 0}
3004
+
3005
+
3006
+ def provision_start(user_id, force=False):
3007
+ """发起扫码建应用(后台线程跑 Device Flow)。返回 {"ok","state","url","error"}。
3008
+
3009
+ 闸门三条:总闸(TS_FEISHU_PROVISION=0)、SDK 可用性、已有应用需 force;
3010
+ 同一用户重复点击**不重复建流程**(返回既有流程的现状)。「读状态 → 写状态」
3011
+ 全程持 `_PROVISION_LOCK`:并发点两下不会各起一条 Device Flow(复阅 P1)。"""
3012
+ if not provision_enabled():
3013
+ return {"ok": False, "state": "idle", "url": "",
3014
+ "error": f"配置自动化已关闭({PROVISION_ENV}=0)"}
3015
+ if not provision_supported():
3016
+ return {"ok": False, "state": "idle", "url": "",
3017
+ "error": "本机 lark-oapi 版本过低(register_app 需 ≥1.5.5),请升级依赖后重试"}
3018
+ with _PROVISION_LOCK:
3019
+ cur = _PROVISION.get(user_id) or {}
3020
+ if cur.get("state") == "waiting" and \
3021
+ (time.time() - cur.get("started_at", 0)) <= PROVISION_TTL:
3022
+ return {"ok": True, "state": "waiting", "url": cur.get("url", ""), "error": ""}
3023
+ cfg = app_config(user_id)
3024
+ if cfg is not None and not force:
3025
+ return {"ok": False, "state": "idle", "url": "",
3026
+ "error": f"已配置应用 {cfg.get('app_id')};如需换应用请确认后重试"}
3027
+ cancel = threading.Event()
3028
+ _PROVISION[user_id] = {"state": "waiting", "url": "", "error": "", "app_id": "",
3029
+ "steps": [], "started_at": time.time(), "cancel": cancel}
3030
+ threading.Thread(target=_provision_worker, args=(user_id, cancel), daemon=True,
3031
+ name=f"feishu-provision-{user_id}").start()
3032
+ return {"ok": True, "state": "waiting", "url": "", "error": ""}
3033
+
3034
+
3035
+ def provision_cancel(user_id):
3036
+ """取消进行中的扫码流程(置 cancel_event,SDK 侧即刻收尾)。"""
3037
+ st = _PROVISION.get(user_id) or {}
3038
+ if st.get("state") != "waiting":
3039
+ return {"ok": False, "state": st.get("state", "idle"), "error": "当前没有进行中的扫码流程"}
3040
+ st["cancel"].set()
3041
+ st["state"] = "cancelled"
3042
+ return {"ok": True, "state": "cancelled", "error": ""}
3043
+
3044
+
3045
+ def _provision_worker(user_id, cancel):
3046
+ """后台主体:跑 Device Flow(回调里落链接/状态)→ 成功即写凭据 → 起长连接 →
3047
+ 自动补齐应用配置(能力/权限/事件/回调),全过程结果落 `_PROVISION` 供前端轮询。
3048
+ 凭据不入日志;任何异常都转状态文案,绝不外抛。"""
3049
+ st = _PROVISION[user_id]
3050
+
3051
+ def on_qr(url):
3052
+ st["url"] = url or ""
3053
+
3054
+ def on_status(info):
3055
+ # 域切换(飞书/Lark)时链接作废,等 SDK 给新链接(旧 URL 已失效,不展示误导)
3056
+ if isinstance(info, dict) and info.get("status") == "domain_switched":
3057
+ st["url"] = ""
3058
+
3059
+ try:
3060
+ out = _register_app_impl(on_qr, on_status, cancel, _provision_addons())
3061
+ except Exception as e:
3062
+ if st.get("state") != "cancelled":
3063
+ st["state"] = "failed"
3064
+ st["error"] = f"扫码创建失败: {e}"
3065
+ return
3066
+ if st.get("state") == "cancelled":
3067
+ return
3068
+ app_id = (out or {}).get("client_id") or ""
3069
+ secret = (out or {}).get("client_secret") or ""
3070
+ if not (app_id and secret):
3071
+ st["state"] = "failed"
3072
+ st["error"] = "扫码流程未返回应用凭据(可能超时或未确认),可重新发起"
3073
+ return
3074
+ # 合并写库:保留既有 enabled / webhook / base_url 等字段,只换应用凭据
3075
+ cfg = dict(user_config(user_id) or {})
3076
+ cfg["app_id"] = app_id
3077
+ cfg["app_secret"] = secret
3078
+ db.set_feishu_user_cfg(user_id, cfg)
3079
+ _TOKEN_CACHE.pop(app_id, None) # 新凭据:清同 app_id 的旧 token 缓存
3080
+ st["app_id"] = app_id
3081
+ try:
3082
+ start_inbound_for(user_id) # 起长连接(失败只留痕,状态里如实呈现)
3083
+ except Exception as e:
3084
+ print(f"feishu provision: 用户 {user_id} 长连接启动异常: {e!r}",
3085
+ file=sys.stderr, flush=True)
3086
+ applied = provision_apply(user_id)
3087
+ st["steps"] = applied.get("steps") or []
3088
+ st["state"] = "success"
3089
+ st["error"] = "" if applied.get("ok") else \
3090
+ f"应用已创建,但配置补齐未完成:{applied.get('error')}"
3091
+
3092
+
3093
+ # ---------- M5 配置自动化:提交发布 ----------
3094
+
3095
+ def _next_version(items):
3096
+ """版本号递增:取远端版本列表里的**最大语义版本**补丁位 +1(无版本回 1.0.0)。
3097
+ 解析失败的版本(非 x.y.z)忽略;比较用整数元组,避免 "1.0.10" < "1.0.3" 的字符串序陷阱。"""
3098
+ best = None
3099
+ for it in items or []:
3100
+ raw = str((it or {}).get("version") or "").strip()
3101
+ try:
3102
+ nums = tuple(int(x) for x in raw.split("."))
3103
+ except ValueError:
3104
+ continue
3105
+ if len(nums) != 3:
3106
+ continue
3107
+ if best is None or nums > best:
3108
+ best = nums
3109
+ if best is None:
3110
+ return "1.0.0"
3111
+ return "%d.%d.%d" % (best[0], best[1], best[2] + 1)
3112
+
3113
+
3114
+ def provision_publish(user_id, version=None, remark="", changelog=""):
3115
+ """提交发布自建应用(POST application/v7/.../publish):无待发布版本时由飞书自动建版本。
3116
+
3117
+ 版本号缺省自动递增(见 `_next_version`);发布后仍需**租户管理员审批**才线上生效
3118
+ (无 API,走引导)。返回 {"ok","version","version_id","need_patch_scope","error"}。"""
3119
+ if not provision_enabled():
3120
+ return {"ok": False, "version": "", "version_id": "", "need_patch_scope": False,
3121
+ "error": f"配置自动化已关闭({PROVISION_ENV}=0)"}
3122
+ cfg = app_config(user_id)
3123
+ if cfg is None:
3124
+ return {"ok": False, "version": "", "version_id": "", "need_patch_scope": False,
3125
+ "error": "应用凭据未配置(App ID / App Secret),请先保存凭据"}
3126
+ app_id = cfg["app_id"]
3127
+ ver = str(version or "").strip()
3128
+ try:
3129
+ if not ver:
3130
+ out = _rest("GET", _APP_VERSIONS_API % app_id, cfg=cfg)
3131
+ items = ((out.get("data") or {}).get("items")) or []
3132
+ ver = _next_version(items)
3133
+ body = {"version": ver,
3134
+ "remark": remark or "Touchstone 配置自动化",
3135
+ "changelog": changelog or "自动配置:机器人能力 / 权限 / 长连接事件订阅 / 卡片回调",
3136
+ "pc_default_ability": "bot", "mobile_default_ability": "bot"}
3137
+ out = _rest("POST", _APP_PUBLISH_API % app_id, json_body=body, cfg=cfg)
3138
+ data = out.get("data") or {}
3139
+ return {"ok": True, "version": data.get("version") or ver,
3140
+ "version_id": str(data.get("version_id") or ""),
3141
+ "need_patch_scope": False, "error": ""}
3142
+ except FeishuRestError as e:
3143
+ return {"ok": False, "version": ver, "version_id": "",
3144
+ "need_patch_scope": _need_scope(e), "error": _apply_error(e)}
3145
+ except Exception as e:
3146
+ return {"ok": False, "version": ver, "version_id": "",
3147
+ "need_patch_scope": False, "error": f"发布失败: {e!r}"}
3148
+
3149
+
3150
+ # ---------- M5 配置自动化:保存配置(含凭据即时快检) ----------
3151
+
3152
+ def save_user_config(user_id, body):
3153
+ """保存用户飞书配置并即时反馈(服务端设置页唯一写口;语义与既有实现逐字一致):
3154
+ 字段缺省=不改、显式空串=清除(前端对未编辑的 secret/webhook 不下发该键,防误清)。
3155
+
3156
+ 新增:凭据齐全时立刻 `verify_credentials` 快检——今天保存凭据只写库不校验,
3157
+ 填错只能翻日志;`verify` 随响应回给页面做即时提示(凭据不全时为 None,零请求)。
3158
+ 返回 {"inbound_started": bool, "verify": {...}|None}。"""
3159
+ cfg = user_config(user_id)
3160
+ # 推送总控(2026-10-10):与旧 `enabled` 是两个独立开关——旧键只管「无绑定行回落
3161
+ # 默认 webhook」与入站,本键一票否决全部自动推送。缺省=不改(不在 body 里就不动)。
3162
+ if "push_enabled" in body:
3163
+ cfg["push_enabled"] = 1 if body["push_enabled"] else 0
3164
+ if "enabled" in body:
3165
+ cfg["enabled"] = 1 if body["enabled"] else 0
3166
+ for key in ("default_webhook", "default_secret", "base_url",
3167
+ "app_id", "app_secret"):
3168
+ if key in body:
3169
+ cfg[key] = str(body[key] or "").strip()
3170
+ db.set_feishu_user_cfg(user_id, cfg)
3171
+ started = start_inbound_for(user_id)
3172
+ verify = verify_credentials(user_id) if app_config(user_id) else None
3173
+ return {"inbound_started": bool(started), "verify": verify}
3174
+
3175
+
3176
+ def _cli_uid(val):
3177
+ """CLI 的用户定位:显式 --user 优先;缺省时库中恰有一个用户即自动选中。
3178
+ 返回 (user_id, None);定位失败返回 (None, 退出码),原因已打印到 stderr。"""
3179
+ if val:
3180
+ return int(val), None
3181
+ try:
3182
+ users = db.list_users()
3183
+ except Exception as e:
3184
+ print(f"读取用户失败:{e}", file=sys.stderr)
3185
+ return None, 1
3186
+ if len(users) == 1:
3187
+ return users[0]["id"], None
3188
+ if not users:
3189
+ print("库中还没有用户:先启动站点并创建账号", file=sys.stderr)
3190
+ return None, 1
3191
+ names = "、".join(f"{r['username']}(id={r['id']})" for r in users)
3192
+ print(f"库中有多个用户,请用 --user 指定:{names}", file=sys.stderr)
3193
+ return None, 1
3194
+
3195
+
2102
3196
  def _cli():
2103
- """python3 feishu.py selftest <pid> / send --url U --text T / outbox"""
2104
- ap = argparse.ArgumentParser(description="Touchstone 飞书出站推送(M1)")
3197
+ """命令行入口(M1 出站 + M5 配置自动化):
3198
+
3199
+ python3 feishu.py selftest <项目id> / send --url U --text T / outbox
3200
+ python3 feishu.py doctor [--user ID] [--send-probe] # 配置自检(有 fail 退出码 1)
3201
+ python3 feishu.py provision [--user ID] [--force] [--no-wait] # 扫码建应用
3202
+ python3 feishu.py apply [--user ID] # 补齐应用配置
3203
+ python3 feishu.py publish [--user ID] [--version V] # 提交发布
3204
+ """
3205
+ ap = argparse.ArgumentParser(description="Touchstone 飞书集成 CLI(出站推送 / 配置自动化)")
2105
3206
  sub = ap.add_subparsers(dest="cmd", required=True)
2106
3207
  p_st = sub.add_parser("selftest", help="向项目绑定 webhook 同步发一条测试消息")
2107
3208
  p_st.add_argument("project_id", type=int)
@@ -2110,6 +3211,18 @@ def _cli():
2110
3211
  p_send.add_argument("--secret", default="")
2111
3212
  p_send.add_argument("--text", required=True)
2112
3213
  sub.add_parser("outbox", help="查看最近投递记录")
3214
+ p_doc = sub.add_parser("doctor", help="飞书配置自检(八项;有 fail 退出码 1)")
3215
+ p_doc.add_argument("--user", type=int, default=None, help="用户 id(库中只有一个用户可省)")
3216
+ p_doc.add_argument("--send-probe", action="store_true", help="额外发一条测试消息验证发消息权限")
3217
+ p_prov = sub.add_parser("provision", help="扫码创建飞书应用(打印确认链接,手机飞书打开)")
3218
+ p_prov.add_argument("--user", type=int, default=None)
3219
+ p_prov.add_argument("--force", action="store_true", help="已配置应用时也重新创建")
3220
+ p_prov.add_argument("--no-wait", action="store_true", help="发起后立即返回,不等扫码结果")
3221
+ p_apply = sub.add_parser("apply", help="补齐应用配置(机器人能力/权限/事件/回调)")
3222
+ p_apply.add_argument("--user", type=int, default=None)
3223
+ p_pub = sub.add_parser("publish", help="提交发布(需管理员审批或已开免审)")
3224
+ p_pub.add_argument("--user", type=int, default=None)
3225
+ p_pub.add_argument("--version", default="", help="版本号(缺省自动递增)")
2113
3226
  a = ap.parse_args()
2114
3227
  db.init_db()
2115
3228
  if a.cmd == "selftest":
@@ -2129,6 +3242,77 @@ def _cli():
2129
3242
  "payload": _payload(a.text), "id": 0, "retries": 0})
2130
3243
  print("OK" if ok else f"FAIL {err}")
2131
3244
  return 0 if ok else 1
3245
+ if a.cmd == "doctor":
3246
+ uid, rc = _cli_uid(a.user)
3247
+ if uid is None:
3248
+ return rc
3249
+ out = doctor(uid, send_probe=a.send_probe)
3250
+ marks = {"ok": "✅", "fail": "❌", "warn": "⚠️", "skip": "➖"}
3251
+ for it in out["items"]:
3252
+ print(f"{marks.get(it['state'], '?')} {it['label']}:{it['detail']}")
3253
+ if it.get("hint"):
3254
+ print(f" 提示:{it['hint']}")
3255
+ s = out["summary"]
3256
+ print(f"汇总:ok={s.get('ok', 0)} fail={s.get('fail', 0)} "
3257
+ f"warn={s.get('warn', 0)} skip={s.get('skip', 0)}")
3258
+ return 1 if s.get("fail") else 0
3259
+ if a.cmd == "provision":
3260
+ uid, rc = _cli_uid(a.user)
3261
+ if uid is None:
3262
+ return rc
3263
+ out = provision_start(uid, force=a.force)
3264
+ if not out.get("ok"):
3265
+ print(f"发起失败:{out.get('error')}", file=sys.stderr)
3266
+ return 1
3267
+ if out.get("url"):
3268
+ print(f"确认链接(手机飞书打开):{out['url']}")
3269
+ if a.no_wait:
3270
+ print("已发起扫码流程(10 分钟内有效),稍后用 `doctor` 查看结果")
3271
+ return 0
3272
+ print("等待你在飞书里确认…(10 分钟有效)")
3273
+ printed = out.get("url") or ""
3274
+ st = provision_status(uid)
3275
+ deadline = time.time() + PROVISION_TTL
3276
+ while st.get("state") == "waiting" and time.time() < deadline:
3277
+ time.sleep(2)
3278
+ st = provision_status(uid)
3279
+ if st.get("url") and st["url"] != printed: # 链接可能稍后就绪/换域
3280
+ printed = st["url"]
3281
+ print(f"确认链接(手机飞书打开):{printed}")
3282
+ if st.get("state") != "success":
3283
+ print(f"未完成(state={st.get('state')}):{st.get('error') or '等待超时'}",
3284
+ file=sys.stderr)
3285
+ return 1
3286
+ print(f"✅ 应用已创建:{st.get('app_id')}")
3287
+ for s in st.get("steps") or []:
3288
+ print(f" {'✅' if s.get('ok') else '❌'} {s.get('label')}:{s.get('detail')}")
3289
+ if st.get("error"):
3290
+ print(f"⚠️ {st['error']}")
3291
+ return 0
3292
+ if a.cmd == "apply":
3293
+ uid, rc = _cli_uid(a.user)
3294
+ if uid is None:
3295
+ return rc
3296
+ out = provision_apply(uid)
3297
+ for s in out.get("steps") or []:
3298
+ print(f"{'✅' if s.get('ok') else '❌'} {s.get('label')}:{s.get('detail')}")
3299
+ if not out.get("ok"):
3300
+ print(f"补齐失败:{out.get('error')}", file=sys.stderr)
3301
+ return 1
3302
+ print("✅ 应用配置已补齐(需创建版本发布后线上生效;可接着 `publish`)")
3303
+ return 0
3304
+ if a.cmd == "publish":
3305
+ uid, rc = _cli_uid(a.user)
3306
+ if uid is None:
3307
+ return rc
3308
+ out = provision_publish(uid, version=a.version or None)
3309
+ if not out.get("ok"):
3310
+ print(f"发布失败:{out.get('error')}", file=sys.stderr)
3311
+ return 1
3312
+ print(f"✅ 已提交发布:版本 {out.get('version')}"
3313
+ f"(version_id={out.get('version_id') or '-'})")
3314
+ print(" 发布需租户管理员审批(或管理员已开免审)后线上生效")
3315
+ return 0
2132
3316
  for r in db.feishu_outbox_recent(30):
2133
3317
  print(r["id"], r["status"], r["retries"],
2134
3318
  (r["last_error"][:60] or "-"), r["created_at"])