@gavinc-cn/touchstone-dsh 0.1.1 → 0.3.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.
- package/README.md +39 -11
- package/README.zh.md +25 -10
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +805 -72
- package/chat.py +590 -17
- package/db.py +356 -140
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +28 -11
- package/dsh-plugin/lib/agent-driver.js +710 -89
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +529 -39
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/src/client.js +94 -9
- package/dshdriver.py +74 -5
- package/dshevents.py +65 -3
- package/feishu.py +1197 -34
- package/feishu_conv.py +920 -0
- package/lifecycle.py +116 -13
- package/package.json +6 -1
- package/requirements.txt +2 -1
- package/server.py +218 -26
- package/sessparse.py +150 -44
- package/touchstone.py +6 -3
- package/waitq.py +23 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
- package/webui/dist-plugin/index.html +2 -2
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.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 # 发送线程轮询间隔(秒)
|
|
@@ -70,32 +75,71 @@ def user_config(user_id):
|
|
|
70
75
|
return db.get_feishu_user_cfg(user_id)
|
|
71
76
|
|
|
72
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
|
+
|
|
73
117
|
def hook_of(project_id):
|
|
74
|
-
"""
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
118
|
+
"""项目推送**群 webhook 目的地**:绑定行 URL 优先;行内无 url(或无绑定行)时
|
|
119
|
+
回落**项目所有者**的用户级默认 webhook(回落要求其总开关 enabled 开着且配了
|
|
120
|
+
`default_webhook`)。附 base_url(通知「详情」链接)与 user_id(投递记录归属)。
|
|
121
|
+
|
|
122
|
+
事件过滤不在这里判断——一律取 `notify_events`(群与单聊卡片同一闸门),
|
|
123
|
+
事件集为空即视为不推;这样「回落默认 webhook」时也尊重项目的事件勾选
|
|
124
|
+
(2026-10-10 前回落分支写死全事件)。
|
|
78
125
|
返回 {"target","secret","events","base_url","user_id"} 或 None(不推)。"""
|
|
126
|
+
events = notify_events(project_id)
|
|
127
|
+
if not events:
|
|
128
|
+
return None
|
|
79
129
|
row = db.get_feishu_hook(project_id)
|
|
80
130
|
proj = db.get_project(project_id) # 所有者为 0 时(项目被删)回落 user_config(0)={}
|
|
81
131
|
owner_id = proj["user_id"] if proj else 0
|
|
82
132
|
base_url = (user_config(owner_id).get("base_url") or "").rstrip("/") if proj else ""
|
|
83
|
-
if row is not None:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if row["webhook_url"]:
|
|
87
|
-
return {"target": row["webhook_url"],
|
|
88
|
-
"secret": row["webhook_secret"] or "",
|
|
89
|
-
"events": {e for e in FEISHU_EVENTS
|
|
90
|
-
if e in (row["events"] or "")},
|
|
91
|
-
"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}
|
|
92
136
|
cfg = user_config(owner_id)
|
|
93
137
|
if not cfg.get("enabled", True):
|
|
94
138
|
return None
|
|
95
139
|
if not cfg.get("default_webhook"):
|
|
96
140
|
return None
|
|
97
141
|
return {"target": cfg["default_webhook"], "secret": cfg.get("default_secret", ""),
|
|
98
|
-
"events":
|
|
142
|
+
"events": events, "base_url": base_url,
|
|
99
143
|
"user_id": owner_id}
|
|
100
144
|
|
|
101
145
|
|
|
@@ -453,6 +497,19 @@ def _build_interaction_card(ctx, project_name):
|
|
|
453
497
|
kind = ctx.get("kind")
|
|
454
498
|
cid = ctx.get("card_id")
|
|
455
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']})"})
|
|
456
513
|
if kind == "approval":
|
|
457
514
|
lines = [f"**审批请求**:{ctx.get('action') or ctx.get('tool') or '工具调用'}"]
|
|
458
515
|
if ctx.get("tool"):
|
|
@@ -528,11 +585,369 @@ def _build_interaction_card(ctx, project_name):
|
|
|
528
585
|
"body": {"elements": elements}}
|
|
529
586
|
|
|
530
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
|
+
|
|
531
939
|
def _dm_interaction_card(project_id, ctx):
|
|
532
|
-
"""
|
|
940
|
+
"""旁路:向项目所有者的飞书单聊发交互卡片(**受 `notify_events` 闸门约束**,
|
|
941
|
+
再要求账号绑定 + 应用凭据齐全才发)。
|
|
942
|
+
|
|
943
|
+
2026-10-10 修实障:此前这里只看绑定与凭据 ⇒ 用户把项目「启用推送」与全部
|
|
944
|
+
事件取消勾选(甚至关掉用户级总开关)后,单聊卡片仍持续送达,设置形同虚设。
|
|
945
|
+
现在项目的 `blocked_interaction` 未开即直接不发(与群 webhook 同一闸门)。
|
|
533
946
|
卡片是即时交互载体,不走 outbox(过期提问的分钟级重试无意义);失败仅
|
|
534
947
|
留痕——群 webhook 文本推送(push_event)始终是主通道,不受影响。"""
|
|
535
948
|
try:
|
|
949
|
+
if "blocked_interaction" not in notify_events(project_id):
|
|
950
|
+
return
|
|
536
951
|
proj = db.get_project(project_id)
|
|
537
952
|
owner = (proj["user_id"] if proj else 0) or 0
|
|
538
953
|
binding = db.get_feishu_binding_by_user(owner)
|
|
@@ -540,6 +955,10 @@ def _dm_interaction_card(project_id, ctx):
|
|
|
540
955
|
if not binding or not cfg:
|
|
541
956
|
return
|
|
542
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 "")
|
|
543
962
|
rest_send_card(binding["open_id"],
|
|
544
963
|
_build_interaction_card(ctx, name), cfg)
|
|
545
964
|
except Exception as e:
|
|
@@ -562,6 +981,10 @@ def card_blocked(project_id, card, interaction):
|
|
|
562
981
|
ctx = {
|
|
563
982
|
"card_id": card["id"], "title": card.get("title") or "",
|
|
564
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 ""),
|
|
565
988
|
"question": interaction.get("question") or "",
|
|
566
989
|
# 选项展示名取 label(交互归一后的字段,见 board._iw_interaction 的
|
|
567
990
|
# dsh 分支;旧代码取不存在的 text 字段,推送里选项序号后恒空白)
|
|
@@ -669,8 +1092,9 @@ def _tenant_token(cfg):
|
|
|
669
1092
|
return out["tenant_access_token"]
|
|
670
1093
|
|
|
671
1094
|
|
|
672
|
-
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):
|
|
673
1096
|
"""飞书 REST 统一调用(按用户应用 cfg 鉴权):非 0 业务码抛 FeishuRestError。
|
|
1097
|
+
timeout 可按调用收敛(M6 建方案文档跑在 board 调和线程里,逐调用上限 8s)。
|
|
674
1098
|
路径规范化:调用方带/不带 /open-apis 前缀均恰好拼出一层(曾因调用方自带前缀
|
|
675
1099
|
+ 此处再拼一层,请求打到 /open-apis/open-apis/... 恒 404 纯文本,且异常被吞
|
|
676
1100
|
表现为「机器人毫无反应」)。
|
|
@@ -687,7 +1111,7 @@ def _rest(method, path, *, params=None, json_body=None, cfg=None):
|
|
|
687
1111
|
try:
|
|
688
1112
|
r = requests.request(
|
|
689
1113
|
method, url, params=params,
|
|
690
|
-
json=json_body, timeout=
|
|
1114
|
+
json=json_body, timeout=timeout,
|
|
691
1115
|
headers={"Authorization": f"Bearer {_tenant_token(cfg)}",
|
|
692
1116
|
"Content-Type": "application/json"})
|
|
693
1117
|
except (OSError, ValueError) as e:
|
|
@@ -769,12 +1193,19 @@ def bot_open_id(cfg):
|
|
|
769
1193
|
|
|
770
1194
|
SLASH_API = "/open-apis/application/v7/app_slash_commands"
|
|
771
1195
|
# (指令名, 中文说明, icon_key):指令名与 _INTENT_RULES 的 /别名 一一对应(顺序即面板顺序);
|
|
772
|
-
# icon_key
|
|
1196
|
+
# icon_key **必须**取 SLASH_ICON_KEYS(官方「Slash Command Icon Key 说明」全表)内的值,
|
|
1197
|
+
# 表外的键(含按语义自造的)创建即报 40000031
|
|
773
1198
|
SLASH_COMMANDS = (
|
|
774
1199
|
("help", "查看可用指令", "skill_outlined"),
|
|
775
1200
|
("bind", "绑定站点账号:/bind 绑定码", "member_outlined"),
|
|
776
1201
|
("unbind", "解除站点账号绑定", "clear_outlined"),
|
|
777
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"),
|
|
778
1209
|
("status", "查看任务与看板摘要:/status [项目名]", "database_outlined"),
|
|
779
1210
|
("cards", "查找卡片:/cards 关键字(id 前缀 / 标题包含)", "codeblock_outlined"),
|
|
780
1211
|
("approve", "通过待审核卡片:/approve 卡片", "flag_outlined"),
|
|
@@ -788,6 +1219,84 @@ _SLASH_NAMES = tuple(c for c, _, _ in SLASH_COMMANDS)
|
|
|
788
1219
|
_SLASH_PERM_CODES = (99991640, 99991672, 99991679)
|
|
789
1220
|
_SLASH_PERM_HINT = ("应用缺少「应用指令」权限:飞书开发者后台 → 权限管理,添加 "
|
|
790
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"
|
|
791
1300
|
|
|
792
1301
|
|
|
793
1302
|
def _slash_description(desc):
|
|
@@ -828,7 +1337,7 @@ def slash_error_hint(err):
|
|
|
828
1337
|
if code in _SLASH_PERM_CODES:
|
|
829
1338
|
return _SLASH_PERM_HINT
|
|
830
1339
|
if code == 40000031:
|
|
831
|
-
return
|
|
1340
|
+
return _SLASH_ICON_HINT
|
|
832
1341
|
if code == 99992402:
|
|
833
1342
|
return "指令参数校验失败:指令名需为不含「/」的标识符,说明不可为空"
|
|
834
1343
|
return str(err)
|
|
@@ -938,6 +1447,11 @@ def sync_slash_commands(user_id):
|
|
|
938
1447
|
failed = []
|
|
939
1448
|
need_scope = False
|
|
940
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
|
|
941
1455
|
try:
|
|
942
1456
|
buckets[_slash_upsert(cfg, name, desc, icon, by_name)].append(name)
|
|
943
1457
|
except FeishuRestError as e:
|
|
@@ -1064,9 +1578,16 @@ def _on_slash_perm_action(sender, value, cfg):
|
|
|
1064
1578
|
|
|
1065
1579
|
HELP_TEXT = (
|
|
1066
1580
|
"Touchstone 指令(单聊可用):\n"
|
|
1581
|
+
"直接发普通文字 = 与当前会话对话(未绑定会在默认项目自动新建)。\n"
|
|
1067
1582
|
"· 帮助 — 本说明\n"
|
|
1068
1583
|
"· 绑定 <码> — 绑定站点账号(绑定码在站点侧栏「设置」→ 飞书设置 生成)\n"
|
|
1069
1584
|
"· 解绑 — 解除绑定\n"
|
|
1585
|
+
"· 当前 — 查看默认项目与当前会话\n"
|
|
1586
|
+
"· 项目 — 项目总览卡(点选即设为默认项目)\n"
|
|
1587
|
+
"· 会话 — 默认项目最近活跃的 10 个会话(点选即绑定)\n"
|
|
1588
|
+
"· 新建会话 [标题] — 在默认项目下新建并绑定\n"
|
|
1589
|
+
"· 绑定会话 <序号|标题关键字|会话id前缀> — 绑定已有会话\n"
|
|
1590
|
+
"· 解绑会话 — 只解绑会话,保留默认项目\n"
|
|
1070
1591
|
"· 默认项目 <项目名> — 设置默认项目\n"
|
|
1071
1592
|
"· 状态 [项目名] — 任务与看板摘要\n"
|
|
1072
1593
|
"· 卡片 <关键字> — 找卡片(id 前缀/标题包含)\n"
|
|
@@ -1210,11 +1731,13 @@ def _ws_run(user_id, cfg):
|
|
|
1210
1731
|
return # 依赖未装:入站禁用(M1 出站 webhook 不受影响)
|
|
1211
1732
|
|
|
1212
1733
|
def _handler(data):
|
|
1734
|
+
_touch_event(user_id) # 事件到达打点(doctor 判「订阅是否真生效」)
|
|
1213
1735
|
_on_message_event(data, cfg)
|
|
1214
1736
|
|
|
1215
1737
|
def _on_card_sdk(data):
|
|
1216
1738
|
# 返回值(toast)由 ws 客户端序列化回响应帧——丢弃则点击反馈(toast)
|
|
1217
1739
|
# 恒不生效(SDK 路径此前即如此)
|
|
1740
|
+
_touch_event(user_id)
|
|
1218
1741
|
return _on_card_action(_card_sdk_to_dict(data), cfg)
|
|
1219
1742
|
|
|
1220
1743
|
handler = (lark.EventDispatcherHandler.builder("", "")
|
|
@@ -1273,7 +1796,9 @@ def _seen_message(mid):
|
|
|
1273
1796
|
def handle_message_event(evt, cfg=None):
|
|
1274
1797
|
"""入站消息主流程(M2 规则指令全为快路径,内联同步执行;M3 agent 解析需挪线程):
|
|
1275
1798
|
去重 → 群聊 @ 判定(未 @ 静默)→ 文本抽取(剥 @占位符)→ 绑定校验(未绑引导)
|
|
1276
|
-
→
|
|
1799
|
+
→ 意图解析 → 执行 → reply。**未识别文本**不是一律回帮助:单聊且通用对话开启
|
|
1800
|
+
(`feishu_conv.enabled()`)时走 `feishu_conv.route_text` 投递给绑定的会话;群聊
|
|
1801
|
+
与 `TS_FEISHU_CONV=0` 才回 `HELP_TEXT`(feishu.py:1337-1343)。任何异常吞掉。
|
|
1277
1802
|
cfg = 连接所有者(收到消息的应用)的凭据,供回复/群聊判定使用。"""
|
|
1278
1803
|
try:
|
|
1279
1804
|
mid = evt.get("message_id")
|
|
@@ -1321,9 +1846,14 @@ def handle_message_event(evt, cfg=None):
|
|
|
1321
1846
|
"生成绑定码后在这里回复「绑定 <码>」完成绑定", cfg)
|
|
1322
1847
|
return
|
|
1323
1848
|
if intent is None:
|
|
1324
|
-
|
|
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)
|
|
1325
1855
|
return
|
|
1326
|
-
reply = execute_intent(binding, intent, sender)
|
|
1856
|
+
reply = execute_intent(binding, intent, sender, cfg)
|
|
1327
1857
|
if reply:
|
|
1328
1858
|
rest_reply(mid, reply, cfg)
|
|
1329
1859
|
except Exception as e:
|
|
@@ -1355,7 +1885,8 @@ def _on_card_action(data, cfg):
|
|
|
1355
1885
|
"""卡片点击回调主流程:event_id 去重 → open_id 绑定校验(未绑 DM 引导)→
|
|
1356
1886
|
动作分发(t=a 单题选项作答 / mq 多选类点选(多子题逐题点选、单题多选
|
|
1357
1887
|
mini 表单)/ p 批准 / s 会话内批准 /
|
|
1358
|
-
d 拒绝 / sc
|
|
1888
|
+
d 拒绝 / sc 快捷指令权限卡片「我已开通,重试注册」/
|
|
1889
|
+
dp 项目总览卡点选设默认项目 / ss 会话列表卡点选绑定会话)→ 与文本指令共用的
|
|
1359
1890
|
_answer_by_token / _answer_multi_click / _decide_approval → DM 文本完整回执 + 返回 toast。
|
|
1360
1891
|
value 精简键:t 动作(a 单题作答 / mq 多选类点选 / p 批准 / s 会话内批准 /
|
|
1361
1892
|
d 拒绝)、c 卡号、n 题号(多题点选用)、i 选项序号(序号在执行器里重读服务端
|
|
@@ -1377,6 +1908,15 @@ def _on_card_action(data, cfg):
|
|
|
1377
1908
|
# M4 权限引导卡片(t=sc)独立于卡片交互流:不带卡号,回调里重跑斜杠指令同步
|
|
1378
1909
|
if t == "sc":
|
|
1379
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)
|
|
1380
1920
|
if t not in ("a", "mq", "p", "s", "d") or not key:
|
|
1381
1921
|
return None
|
|
1382
1922
|
binding = db.get_feishu_binding_by_open(sender)
|
|
@@ -1447,6 +1987,12 @@ _INTENT_RULES = (
|
|
|
1447
1987
|
("bind", r"^(?:绑定|/bind)\s*([A-Za-z0-9]{4,8})$"),
|
|
1448
1988
|
("unbind", r"^(?:解绑|/unbind)$"),
|
|
1449
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)$"),
|
|
1450
1996
|
("status", r"^(?:状态|/status)(?:\s+(.+))?$"),
|
|
1451
1997
|
("card", r"^(?:卡片|/cards?)\s+(.+)$"),
|
|
1452
1998
|
("approve", r"^(?:通过|/approve)\s+(\S+)$"),
|
|
@@ -1489,9 +2035,10 @@ def bind_user(code, open_id):
|
|
|
1489
2035
|
return None
|
|
1490
2036
|
|
|
1491
2037
|
|
|
1492
|
-
def execute_intent(binding, intent, sender_open_id):
|
|
2038
|
+
def execute_intent(binding, intent, sender_open_id, cfg=None):
|
|
1493
2039
|
"""绑定用户身份执行意图;返回回复文案(空串=不回复)。项目/卡片访问一律先过
|
|
1494
|
-
归属(list_projects(user_id) 限用户项目),异常统一转友好文案。
|
|
2040
|
+
归属(list_projects(user_id) 限用户项目),异常统一转友好文案。
|
|
2041
|
+
cfg = 本次消息所属应用的凭据(转交通用对话层做卡片/回执)。"""
|
|
1495
2042
|
action, g = intent["action"], intent["groups"]
|
|
1496
2043
|
try:
|
|
1497
2044
|
if action == "help":
|
|
@@ -1503,6 +2050,8 @@ def execute_intent(binding, intent, sender_open_id):
|
|
|
1503
2050
|
return "已解绑。重新绑定请到站点生成新的绑定码"
|
|
1504
2051
|
if action == "default_project":
|
|
1505
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)
|
|
1506
2055
|
return _execute_board_action(binding, intent, sender_open_id)
|
|
1507
2056
|
except FeishuRestError:
|
|
1508
2057
|
return "飞书接口调用失败,请稍后重试"
|
|
@@ -1510,18 +2059,23 @@ def execute_intent(binding, intent, sender_open_id):
|
|
|
1510
2059
|
return "指令执行失败,请稍后重试"
|
|
1511
2060
|
|
|
1512
2061
|
|
|
1513
|
-
def _set_default_project(binding, name):
|
|
1514
|
-
"""
|
|
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)
|
|
1515
2071
|
user_id = binding["user_id"]
|
|
1516
2072
|
projects = db.list_projects(user_id)
|
|
1517
2073
|
for p in projects:
|
|
1518
2074
|
if p["name"] == name:
|
|
1519
|
-
|
|
1520
|
-
return f"默认项目已设为「{name}」"
|
|
2075
|
+
return feishu_conv.set_default_project(binding, p)
|
|
1521
2076
|
hits = [p for p in projects if name in p["name"]]
|
|
1522
2077
|
if len(hits) == 1:
|
|
1523
|
-
|
|
1524
|
-
return f"默认项目已设为「{hits[0]['name']}」"
|
|
2078
|
+
return feishu_conv.set_default_project(binding, hits[0])
|
|
1525
2079
|
return f"项目「{name}」不在你的项目列表"
|
|
1526
2080
|
|
|
1527
2081
|
|
|
@@ -2109,20 +2663,546 @@ def _send_loop():
|
|
|
2109
2663
|
|
|
2110
2664
|
|
|
2111
2665
|
def start_notifier():
|
|
2112
|
-
"""幂等启动投递线程(server main 启动时调用一次;未配置时空转无害)。
|
|
2666
|
+
"""幂等启动投递线程(server main 启动时调用一次;未配置时空转无害)。
|
|
2667
|
+
|
|
2668
|
+
同时注册通用对话的消息终态钩子(feishu_conv.start,幂等):答复回流靠它把
|
|
2669
|
+
「跑完的会话消息」推回飞书 DM——这是本模块唯一的接线点(server.main 已调用
|
|
2670
|
+
本函数),漏掉则回流全哑且无任何报错。
|
|
2671
|
+
"""
|
|
2113
2672
|
global _SENDER_STARTED
|
|
2114
2673
|
if _SENDER_STARTED:
|
|
2115
2674
|
return
|
|
2116
2675
|
_SENDER_STARTED = True
|
|
2676
|
+
feishu_conv.start() # 幂等:注册 chat 的消息终态钩子
|
|
2117
2677
|
threading.Thread(target=_send_loop, daemon=True,
|
|
2118
2678
|
name="feishu-notifier").start()
|
|
2119
2679
|
|
|
2120
2680
|
|
|
2121
2681
|
# ---------- CLI(自测通道) ----------
|
|
2122
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
|
+
|
|
2123
3196
|
def _cli():
|
|
2124
|
-
"""
|
|
2125
|
-
|
|
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(出站推送 / 配置自动化)")
|
|
2126
3206
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
2127
3207
|
p_st = sub.add_parser("selftest", help="向项目绑定 webhook 同步发一条测试消息")
|
|
2128
3208
|
p_st.add_argument("project_id", type=int)
|
|
@@ -2131,6 +3211,18 @@ def _cli():
|
|
|
2131
3211
|
p_send.add_argument("--secret", default="")
|
|
2132
3212
|
p_send.add_argument("--text", required=True)
|
|
2133
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="版本号(缺省自动递增)")
|
|
2134
3226
|
a = ap.parse_args()
|
|
2135
3227
|
db.init_db()
|
|
2136
3228
|
if a.cmd == "selftest":
|
|
@@ -2150,6 +3242,77 @@ def _cli():
|
|
|
2150
3242
|
"payload": _payload(a.text), "id": 0, "retries": 0})
|
|
2151
3243
|
print("OK" if ok else f"FAIL {err}")
|
|
2152
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
|
|
2153
3316
|
for r in db.feishu_outbox_recent(30):
|
|
2154
3317
|
print(r["id"], r["status"], r["retries"],
|
|
2155
3318
|
(r["last_error"][:60] or "-"), r["created_at"])
|