@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_conv.py
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""飞书通用对话(会话对话层):默认项目下的会话绑定、自由文本投递、答复回流、
|
|
3
|
+
项目总览卡与会话列表卡。设计见 doc_ai/plan/202610/20261008_2215_飞书通用对话…设计.md。
|
|
4
|
+
|
|
5
|
+
依赖方向(硬约束):本模块可模块级 import db/sessparse/dshdriver/dshevents/agents/waitq;
|
|
6
|
+
chat 与 feishu 一律**函数内懒 import**(feishu → runner → chat 已成环,见 chat.py:166 先例)。
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import agents
|
|
14
|
+
import db
|
|
15
|
+
import dshevents
|
|
16
|
+
import dshdriver
|
|
17
|
+
import sessparse
|
|
18
|
+
import waitq
|
|
19
|
+
|
|
20
|
+
CONV_ENV = "TS_FEISHU_CONV" # "0" 关闭(回到「未识别文本回帮助」)
|
|
21
|
+
CARD_MAX_BYTES = 8000 # 卡片 JSON 体积预算
|
|
22
|
+
PROJECTS_MAX = 20 # 总览卡最多列的项目数
|
|
23
|
+
PROJECTS_FALLBACK = 10 # 超预算时第二轮降级的项目数(brief Step 3:再截到 10)
|
|
24
|
+
PROJECT_NAME_MAX = 200 # 卡片里项目名的展示上限(异常超长名的体积兜底)
|
|
25
|
+
SESSIONS_MAX = 10 # 列表卡最多列的会话数
|
|
26
|
+
LIST_TTL = 1800 # 「绑定会话 <序号>」列表记忆有效期(秒)
|
|
27
|
+
REPLY_MAX_CHARS = 3000 # 答复回流截断长度
|
|
28
|
+
_LAST_LIST = {} # open_id -> (ts, [sid, ...])
|
|
29
|
+
|
|
30
|
+
# 固定文案(测试与 spec 逐字引用,改动需同步三处)
|
|
31
|
+
DRIVER_NEEDED = "飞书通用对话需要 dsh 插件形态(独立形态的站点没有进程内 agent 驱动)"
|
|
32
|
+
NO_DEFAULT_GUIDE = "请先设置默认项目(回复「默认项目 <项目名>」,或发「项目」在卡片上点选)"
|
|
33
|
+
NO_PROJECTS_GUIDE = "你还没有项目,请先到站点创建"
|
|
34
|
+
STALE_NOTE = "(此前绑定的会话已不存在,已解除绑定)"
|
|
35
|
+
UNBIND_NOTE = "已解除此前绑定的会话(下次发消息将在新项目自动新建)"
|
|
36
|
+
PROJECTS_MORE_FMT = "另有 %d 个项目,回复「默认项目 <项目名>」精确设置"
|
|
37
|
+
PROJECTS_TEXT_HINT = "在卡片可用前,可用『默认项目 <项目名>』设置" # 卡片发不出时的文本兜底
|
|
38
|
+
SESSIONS_TEXT_HINT = "可用『绑定会话 <序号|标题关键字|会话id前缀>』绑定"
|
|
39
|
+
TITLE_FMT = "飞书对话 %m-%d %H:%M" # 新建会话的缺省标题(本地时间)
|
|
40
|
+
SESSION_GONE = "该会话已不存在(可能被删除或归档清理),发「会话」刷新列表"
|
|
41
|
+
SESSION_EMPTY = ("该默认项目下还没有会话——直接发一句话就会自动新建,"
|
|
42
|
+
"或回复「新建会话 <标题>」")
|
|
43
|
+
SESSION_STALE = "列表记忆已过期或序号越界,先发「会话」刷新最近 10 条,再回复「绑定会话 <序号>」"
|
|
44
|
+
SESSION_MISS_FMT = "没找到匹配「%s」的会话,发「会话」看最近 10 条"
|
|
45
|
+
SESSION_MULTI_FMT = "「%s」匹配到多个会话,回复更精确的关键字:\n%s"
|
|
46
|
+
# bind_session 的失败回执(`t=ss` 回调据此选 toast 类型;文案与文本兜底逐字共用)
|
|
47
|
+
SESSION_FAIL_TEXTS = (NO_DEFAULT_GUIDE, SESSION_GONE)
|
|
48
|
+
SESSION_NAME_MAX = 60 # 会话名在卡片里的展示上限(异常超长标题的体积兜底)
|
|
49
|
+
MESSAGE_TOO_LONG = ("消息过长(单条上限 %d 字):请把长内容作为附件/文件发送,"
|
|
50
|
+
"或拆成几条短消息再发")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def enabled():
|
|
54
|
+
"""通用对话总开关(默认开;TS_FEISHU_CONV=0 关闭)。"""
|
|
55
|
+
return (os.environ.get(CONV_ENV) or "").strip() != "0"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def current_text(binding):
|
|
59
|
+
"""「当前」回执:默认项目 + 当前会话(标题 / sid 前 12 位 / 绑定时间 / 所属项目);
|
|
60
|
+
未绑定会话时明说「未绑定会话,发消息将自动新建」。"""
|
|
61
|
+
project = _default_project(binding)
|
|
62
|
+
lines = [f"默认项目:{project['name'] if project else '未设置'}"]
|
|
63
|
+
sid = (binding["cur_sid"] or "").strip()
|
|
64
|
+
if not sid:
|
|
65
|
+
lines.append("当前会话:未绑定会话,发消息将自动新建")
|
|
66
|
+
return "\n".join(lines)
|
|
67
|
+
title = (binding["cur_title"] or "").strip() or sid[:12]
|
|
68
|
+
lines.append(f"当前会话:{title}({sid[:12]})")
|
|
69
|
+
bound_at = (binding["cur_bound_at"] or "").strip()
|
|
70
|
+
if bound_at:
|
|
71
|
+
lines.append(f"绑定时间:{bound_at}")
|
|
72
|
+
owner = _project_by_id(binding, binding["cur_project_id"])
|
|
73
|
+
lines.append(f"所属项目:{owner['name'] if owner else '(项目已不在你的列表)'}")
|
|
74
|
+
return "\n".join(lines)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def new_session(binding, title=""):
|
|
78
|
+
"""「新建会话 [标题]」:默认项目下新建 dsh 会话并绑定,回执写明项目与看板副作用
|
|
79
|
+
(新会话落在项目 project_dir,看板 sync_sessions 会自动建一张 sync 卡,不占运行位)。"""
|
|
80
|
+
project, err = _locate_project(binding)
|
|
81
|
+
if project is None:
|
|
82
|
+
return err
|
|
83
|
+
title = (title or "").strip() or _new_title()
|
|
84
|
+
sid, err = _create_in_project(binding, project, title)
|
|
85
|
+
if err:
|
|
86
|
+
return err
|
|
87
|
+
return (f"已新建并绑定「{title}」(项目:{project['name']}),接下来发消息都进这个会话;"
|
|
88
|
+
f"它也会出现在看板(sync 卡,不占运行位)")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def route_text(binding, text, cfg=None):
|
|
92
|
+
"""单聊自由文本路由(四分支):有会话直投 → 无会话按默认项目自动新建 → 投递 → 引导。
|
|
93
|
+
|
|
94
|
+
0. 长度闸:超 `chat.MESSAGE_MAX`(与站点 `POST …/messages` 同一常量)⇒ 直接回
|
|
95
|
+
中文回执并给两条出路(改发文件/附件、或拆成几条),**什么都不提交**;
|
|
96
|
+
1. 有 `cur_sid` 且项目仍归本人、会话仍存在 ⇒ 直接投递;
|
|
97
|
+
失效(会话被删/归档清理/项目易主)⇒ 自动解绑 + 留痕提示,再按「无会话」处理;
|
|
98
|
+
2. 无会话 ⇒ 默认项目(回落到用户唯一项目)下自动新建并绑定,再投递;
|
|
99
|
+
3. 投递走 `chat.submit`(统一队列 `m:` 行,与站点会话消息同路、不做注入插队),
|
|
100
|
+
`extra_meta` 带 `baseline` 供答复回流只取本轮新增;
|
|
101
|
+
4. 无默认项目(多项目未指定 / 无项目)⇒ 回引导文案 + **项目总览卡**(点选即设默认)。
|
|
102
|
+
"""
|
|
103
|
+
import chat # 函数内懒 import:feishu → runner → chat 已成环(chat.py:166 先例)
|
|
104
|
+
if len(text or "") > chat.MESSAGE_MAX:
|
|
105
|
+
return MESSAGE_TOO_LONG % chat.MESSAGE_MAX
|
|
106
|
+
open_id = binding["open_id"]
|
|
107
|
+
note = ""
|
|
108
|
+
sid = (binding["cur_sid"] or "").strip()
|
|
109
|
+
title = (binding["cur_title"] or "").strip()
|
|
110
|
+
project = None
|
|
111
|
+
if sid:
|
|
112
|
+
project = _project_by_id(binding, binding["cur_project_id"])
|
|
113
|
+
if project is None or not sessparse.session_exists("dsh", sid):
|
|
114
|
+
db.clear_feishu_cur_session(open_id)
|
|
115
|
+
sid, title, project = "", "", None
|
|
116
|
+
note = STALE_NOTE
|
|
117
|
+
if not sid:
|
|
118
|
+
project, _err = _locate_project(binding)
|
|
119
|
+
if project is None:
|
|
120
|
+
# 设计 §3.4 分支 4:引导文案 + 项目总览卡(无项目时 projects_card 为 None,
|
|
121
|
+
# 只回文案,绝不给一张空卡);卡片发送走凭据闸,缺凭据只留痕
|
|
122
|
+
card = projects_card(binding)
|
|
123
|
+
if card is not None:
|
|
124
|
+
send_card(open_id, card, cfg)
|
|
125
|
+
return NO_DEFAULT_GUIDE + (("\n" + note) if note else "")
|
|
126
|
+
title = _new_title()
|
|
127
|
+
sid, err = _create_in_project(binding, project, title)
|
|
128
|
+
if err:
|
|
129
|
+
return err + (("\n" + note) if note else "")
|
|
130
|
+
# 创建路径内已写绑定;这里重申一次(幂等):路由侧不依赖创建函数的副作用
|
|
131
|
+
# ——「这条消息投到哪个会话」必须与库里的绑定指针一致
|
|
132
|
+
db.set_feishu_cur_session(open_id, project["id"], sid, title)
|
|
133
|
+
if not title:
|
|
134
|
+
title = sid[:12]
|
|
135
|
+
baseline = _baseline_of(sid)
|
|
136
|
+
try:
|
|
137
|
+
res = chat.submit(project["id"], sid, text,
|
|
138
|
+
family=agents.agent_family(project["agent_path"]),
|
|
139
|
+
extra_meta={"feishu": {"open_id": open_id,
|
|
140
|
+
"user_id": binding["user_id"],
|
|
141
|
+
"project_id": project["id"],
|
|
142
|
+
"baseline": baseline,
|
|
143
|
+
"title": title}})
|
|
144
|
+
except Exception as e:
|
|
145
|
+
_trace(f"投递失败 sid={sid}: {e!r}")
|
|
146
|
+
return f"投递失败:{e}" + (("\n" + note) if note else "")
|
|
147
|
+
state = "排队中" if res.get("queued") else "执行中"
|
|
148
|
+
return f"已投递到「{title}」({state}),跑完把答复发回这里" + note
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def conv_intent(binding, action, groups, sender_open_id, cfg=None):
|
|
152
|
+
"""六条新指令的执行器(feishu.execute_intent 分派进来)。
|
|
153
|
+
|
|
154
|
+
`current` / `new` / `use` / `leave` 回文本;`projects` / `sessions` 发对应卡片
|
|
155
|
+
(卡片即回执,返回空串 ⇒ 调用方不另发文本);未知动作兜底回帮助(不静默、不留白)。
|
|
156
|
+
"""
|
|
157
|
+
g = groups or []
|
|
158
|
+
if action == "current":
|
|
159
|
+
return current_text(binding)
|
|
160
|
+
if action == "projects":
|
|
161
|
+
# 项目总览卡:卡片自带点选控件(t=dp 回调设默认项目);无项目才回文本
|
|
162
|
+
card = projects_card(binding)
|
|
163
|
+
if card is None:
|
|
164
|
+
return NO_PROJECTS_GUIDE
|
|
165
|
+
if not send_card(binding["open_id"], card, cfg):
|
|
166
|
+
return _projects_text(binding) # 卡片没被受理 ⇒ 文本兜底,绝不静默
|
|
167
|
+
return "" # 卡片即回执,不另发文本(execute_intent 见空串不发)
|
|
168
|
+
if action == "sessions":
|
|
169
|
+
# 会话列表卡(t=ss 回调绑定):无默认项目回文本引导(卡片无处可列)
|
|
170
|
+
card = sessions_card(binding)
|
|
171
|
+
if card is None:
|
|
172
|
+
return NO_DEFAULT_GUIDE
|
|
173
|
+
if not send_card(binding["open_id"], card, cfg):
|
|
174
|
+
return _sessions_text(binding) # 卡片没被受理 ⇒ 文本兜底,绝不静默
|
|
175
|
+
return "" # 同上:卡片即回执
|
|
176
|
+
if action == "new":
|
|
177
|
+
return new_session(binding, ((g or [None])[0] or "").strip())
|
|
178
|
+
if action == "use":
|
|
179
|
+
# 文本兜底绑定:序号(上一张列表卡的顺序)/ 标题包含 / sid 前缀
|
|
180
|
+
return use_by_token(binding, g[0] if g else "")
|
|
181
|
+
if action == "leave":
|
|
182
|
+
return leave_session(binding)
|
|
183
|
+
import feishu # 函数内懒 import(同 route_text):未知动作兜底回帮助
|
|
184
|
+
return feishu.HELP_TEXT
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------- 项目总览卡与 t=dp 回调(Task 4) ----------
|
|
188
|
+
#
|
|
189
|
+
# 卡片形态(设计 §3.6):每项目一块 markdown(名称 + 标记 + 计数行)+ **唯一**根级
|
|
190
|
+
# `select_static`(option.value = 项目 id),点选即回调 `t=dp` 设默认项目。飞书规定
|
|
191
|
+
# 多选/表单类组件才需 form 容器,单选下拉放根级最省体积也最好用(无 form 元素)。
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class _Card(dict):
|
|
195
|
+
"""卡片对象(dict 子类):元素按飞书 schema 2.0 放在 `body.elements`
|
|
196
|
+
(与 `feishu._build_interaction_card` 同构,`rest_send_card` 直接 `json.dumps`
|
|
197
|
+
发出);另把 `card["elements"]` 作**只读别名**指向同一列表,便于调用方/用例
|
|
198
|
+
按元素遍历。别名只在读口生效:`json.dumps` 走真实键,发出去的 JSON 里没有
|
|
199
|
+
顶层 elements(既不多占体积预算,也不会因未知字段被飞书拒收)。
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __getitem__(self, key):
|
|
203
|
+
if key == "elements" and not dict.__contains__(self, key):
|
|
204
|
+
return dict.__getitem__(self, "body")["elements"]
|
|
205
|
+
return dict.__getitem__(self, key)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _card(header, elements):
|
|
209
|
+
"""构造卡片 dict(见 `_Card`):header=卡片头,elements=根级元素列表。"""
|
|
210
|
+
return _Card({"schema": "2.0", "header": header, "body": {"elements": elements}})
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _card_bytes(card):
|
|
214
|
+
"""卡片实际发出的体积(字节):与 `rest_send_card` 同一序列化口径
|
|
215
|
+
(`json.dumps(..., ensure_ascii=False)` 的 UTF-8 字节数)。"""
|
|
216
|
+
return len(json.dumps(card, ensure_ascii=False).encode("utf-8"))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def projects_card(binding):
|
|
220
|
+
"""项目总览卡(`项目` 指令):用户没有任何项目时返回 None(调用方回文本引导)。
|
|
221
|
+
|
|
222
|
+
排序:默认项目置顶,其余按 id 升序(下拉选项同序)。体积三道闸(飞书对超限
|
|
223
|
+
卡片整条拒收,宁可少列也不能发不出去):
|
|
224
|
+
① 最多列 PROJECTS_MAX 个项目,其余折成一行文本引导(点选仍可设默认);
|
|
225
|
+
② 仍超 CARD_MAX_BYTES ⇒ 删计数行(保留名称 + 标记);
|
|
226
|
+
③ 仍超 ⇒ 项目数降到 PROJECTS_FALLBACK 再逐次折半(至少留 1 个)。
|
|
227
|
+
"""
|
|
228
|
+
projects = _projects_of(binding)
|
|
229
|
+
if not projects:
|
|
230
|
+
return None
|
|
231
|
+
default = _default_project(binding)
|
|
232
|
+
default_id = int(default["id"]) if default else 0
|
|
233
|
+
cur_pid = int(_binding_value(binding, "cur_project_id", 0) or 0)
|
|
234
|
+
if not str(_binding_value(binding, "cur_sid", "") or "").strip():
|
|
235
|
+
cur_pid = 0 # 会话指针为空时 cur_project_id 无意义(不标 💬)
|
|
236
|
+
ordered = sorted(projects,
|
|
237
|
+
key=lambda p: (0 if int(p["id"]) == default_id else 1, int(p["id"])))
|
|
238
|
+
rows = [(int(p["id"]), _clip_name(p["name"]),
|
|
239
|
+
[m for m, on in (("✅", int(p["id"]) == default_id),
|
|
240
|
+
("💬", bool(cur_pid) and int(p["id"]) == cur_pid)) if on])
|
|
241
|
+
for p in ordered]
|
|
242
|
+
counts = [_counts_line(p) for p in ordered[:PROJECTS_MAX]] # 只算会展示的前 N 个
|
|
243
|
+
title = _clip_name(default["name"]) if default else "未设置"
|
|
244
|
+
header = {"title": {"tag": "plain_text", "content": f"项目总览(默认:{title})"},
|
|
245
|
+
"template": "orange"}
|
|
246
|
+
total = len(rows)
|
|
247
|
+
shown = min(total, PROJECTS_MAX)
|
|
248
|
+
card = _card(header, _projects_elements(binding, rows, counts, shown, True))
|
|
249
|
+
if _card_bytes(card) > CARD_MAX_BYTES: # 降级 ②:删计数行
|
|
250
|
+
card = _card(header, _projects_elements(binding, rows, counts, shown, False))
|
|
251
|
+
limit = PROJECTS_FALLBACK # 降级 ③:收缩项目数
|
|
252
|
+
while _card_bytes(card) > CARD_MAX_BYTES and shown > 1:
|
|
253
|
+
shown = min(shown, limit)
|
|
254
|
+
card = _card(header, _projects_elements(binding, rows, counts, shown, False))
|
|
255
|
+
limit = max(1, limit // 2)
|
|
256
|
+
return card
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _projects_elements(binding, rows, counts, shown, with_counts):
|
|
260
|
+
"""总览卡根级元素:前 shown 个项目各一块 markdown(`**名称** ✅/💬` [+ 计数行]),
|
|
261
|
+
有项目没列全时补一行文本引导,末尾是唯一的根级下拉控件(value=项目 id)。"""
|
|
262
|
+
els = []
|
|
263
|
+
for i, (_pid, name, marks) in enumerate(rows[:shown]):
|
|
264
|
+
head = "**%s**" % name + ((" " + " ".join(marks)) if marks else "")
|
|
265
|
+
els.append({"tag": "markdown",
|
|
266
|
+
"content": head + (("\n" + counts[i]) if with_counts else "")})
|
|
267
|
+
extra = len(rows) - shown
|
|
268
|
+
if extra > 0:
|
|
269
|
+
els.append({"tag": "markdown", "content": PROJECTS_MORE_FMT % extra})
|
|
270
|
+
els.append({"tag": "select_static", "name": "ts_projects",
|
|
271
|
+
"placeholder": {"tag": "plain_text", "content": "选择项目设为默认"},
|
|
272
|
+
"options": [{"text": {"tag": "plain_text", "content": name[:24]},
|
|
273
|
+
"value": str(pid)} for pid, name, _m in rows[:shown]],
|
|
274
|
+
"behaviors": [{"type": "callback",
|
|
275
|
+
"value": {"t": "dp", "u": binding["user_id"]}}]})
|
|
276
|
+
return els
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _projects_text(binding):
|
|
280
|
+
"""总览卡的**纯文本兜底**(卡片未被受理时):项目清单 + 文本设置指引。
|
|
281
|
+
|
|
282
|
+
排序与卡片一致(默认项目置顶,其余按 id 升序),默认项目打 ✅;项目名同样过
|
|
283
|
+
`_clip_name`。文本没有卡片的 8000 字节预算问题,故不做体积降级、也不裁条数。
|
|
284
|
+
"""
|
|
285
|
+
projects = _projects_of(binding)
|
|
286
|
+
if not projects:
|
|
287
|
+
return NO_PROJECTS_GUIDE
|
|
288
|
+
default = _default_project(binding)
|
|
289
|
+
default_id = int(default["id"]) if default else 0
|
|
290
|
+
ordered = sorted(projects,
|
|
291
|
+
key=lambda p: (0 if int(p["id"]) == default_id else 1, int(p["id"])))
|
|
292
|
+
lines = ["项目列表:"]
|
|
293
|
+
for p in ordered:
|
|
294
|
+
mark = " ✅默认" if int(p["id"]) == default_id else ""
|
|
295
|
+
lines.append(f"· {_clip_name(p['name'])}{mark}")
|
|
296
|
+
lines.append(PROJECTS_TEXT_HINT)
|
|
297
|
+
return "\n".join(lines)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _counts_line(project):
|
|
301
|
+
"""单项目计数行:任务(运行中/排队)· 看板五列 · 队列四类活跃行。
|
|
302
|
+
|
|
303
|
+
口径:任务取 `db.list_tasks` 的 status(与站点「运行中/排队」一致);队列行取
|
|
304
|
+
`waitq.active_items` 按 kind 分组(`task` 与任务状态同源,不重复计)。
|
|
305
|
+
"""
|
|
306
|
+
pid = project["id"]
|
|
307
|
+
running = queued = 0
|
|
308
|
+
for t in db.list_tasks(pid):
|
|
309
|
+
status = str(t["status"] or "")
|
|
310
|
+
if status == "running":
|
|
311
|
+
running += 1
|
|
312
|
+
elif status == "queued":
|
|
313
|
+
queued += 1
|
|
314
|
+
board = _board_counts(pid)
|
|
315
|
+
kinds = {k: 0 for k in (waitq.KIND_CARD, waitq.KIND_MSG,
|
|
316
|
+
waitq.KIND_ANSWER, waitq.KIND_EXT)}
|
|
317
|
+
for it in waitq.active_items(pid):
|
|
318
|
+
kind = str(it["kind"] or "")
|
|
319
|
+
if kind in kinds:
|
|
320
|
+
kinds[kind] += 1
|
|
321
|
+
return (f"任务 运行 {running}/排队 {queued} · "
|
|
322
|
+
f"看板 待开发 {board['todo']}/开发中 {board['doing']}"
|
|
323
|
+
f"/阻塞 {board['blocked']}/待审核 {board['review']}/已完成 {board['done']} · "
|
|
324
|
+
f"队列 卡 {kinds[waitq.KIND_CARD]}/消息 {kinds[waitq.KIND_MSG]}"
|
|
325
|
+
f"/作答 {kinds[waitq.KIND_ANSWER]}/外部 {kinds[waitq.KIND_EXT]}")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _board_counts(pid):
|
|
329
|
+
"""看板五列卡片数:doing/blocked/review 走 `db.board_counts_many` 批量口(brief
|
|
330
|
+
指定口径),todo/done 该口**不返回**(实测只有三列,db.py:1027)⇒ 由
|
|
331
|
+
`db.list_board_cards` 现算补齐;补齐值无条件覆盖,日后该口扩到五列也不会重复计。"""
|
|
332
|
+
counts = {"todo": 0, "doing": 0, "blocked": 0, "review": 0, "done": 0}
|
|
333
|
+
counts.update(dict(db.board_counts_many([pid]).get(pid) or {}))
|
|
334
|
+
tail = {"todo": 0, "done": 0}
|
|
335
|
+
for card in db.list_board_cards(pid):
|
|
336
|
+
key = str(card["column_key"] or "")
|
|
337
|
+
if key in tail:
|
|
338
|
+
tail[key] += 1
|
|
339
|
+
counts.update(tail)
|
|
340
|
+
return counts
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def set_default_project(binding, project):
|
|
344
|
+
"""设默认项目(卡片回调与文本指令共用同一段写路径)。返回回执文案。
|
|
345
|
+
|
|
346
|
+
跨项目自动解绑(设计 §3.2):当前已绑定会话且会话属于**另一个**项目时,
|
|
347
|
+
一并清掉会话绑定——会话是项目目录下的实体,留着跨项目投递会打到错误的
|
|
348
|
+
工作目录;同一项目则保留会话不动。
|
|
349
|
+
|
|
350
|
+
换项目同时作废「会话列表卡」的序号记忆(`_LAST_LIST` 是**某个项目下**的顺序,
|
|
351
|
+
留着会让「绑定会话 3」把旧项目的会话绑到新项目上);同项目重复设置不动它。
|
|
352
|
+
"""
|
|
353
|
+
open_id = binding["open_id"]
|
|
354
|
+
changed = int(_binding_value(binding, "default_project_id", 0) or 0) != int(project["id"])
|
|
355
|
+
db.set_feishu_default_project(open_id, project["id"])
|
|
356
|
+
if changed:
|
|
357
|
+
_LAST_LIST.pop(open_id, None)
|
|
358
|
+
reply = f"默认项目已设为「{project['name']}」"
|
|
359
|
+
sid = str(_binding_value(binding, "cur_sid", "") or "").strip()
|
|
360
|
+
if sid and int(_binding_value(binding, "cur_project_id", 0) or 0) != int(project["id"]):
|
|
361
|
+
db.clear_feishu_cur_session(open_id)
|
|
362
|
+
reply += "\n" + UNBIND_NOTE
|
|
363
|
+
return reply
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def on_card_action(sender, binding, value, action, cfg):
|
|
367
|
+
"""通用对话卡片回调(`t=dp` 项目总览 / `t=ss` 会话列表):返回 toast;未知 t 返回 None。
|
|
368
|
+
|
|
369
|
+
写操作前两道归属复核(多用户隔离,仿 `feishu._on_slash_perm_action`):卡片
|
|
370
|
+
`value.u` == 点击者 user_id(防串号)且所选项目 ∈ `db.list_projects(user_id)`
|
|
371
|
+
——任一不满足即错误 toast 且**不改任何状态**。`t=ss` 的会话归属由
|
|
372
|
+
`bind_session` 复验**本项目会话桶的成员资格**(`sessparse.list_sessions` 的
|
|
373
|
+
结果集,不是跨 bucket 的 `session_exists`)兜住。`sender` 未用(open_id 从
|
|
374
|
+
binding 取),保留以固定回调签名。
|
|
375
|
+
"""
|
|
376
|
+
value = value or {}
|
|
377
|
+
action = action or {}
|
|
378
|
+
if str(value.get("u") or "") != str(binding["user_id"]):
|
|
379
|
+
return {"toast": {"type": "error", "content": "这张卡片不属于当前账号"}}
|
|
380
|
+
if str(value.get("t") or "") == "dp":
|
|
381
|
+
project = _project_by_id(binding, _selected_project_id(action))
|
|
382
|
+
if project is None:
|
|
383
|
+
return {"toast": {"type": "error", "content": "项目不在你的列表里"}}
|
|
384
|
+
reply = set_default_project(binding, project)
|
|
385
|
+
_dm(binding["open_id"], reply, cfg)
|
|
386
|
+
return {"toast": {"type": "success", "content": "已设为默认项目"}}
|
|
387
|
+
if str(value.get("t") or "") == "ss":
|
|
388
|
+
sid = _selected_session(action)
|
|
389
|
+
if not sid:
|
|
390
|
+
return {"toast": {"type": "error", "content": "没有取到要绑定的会话"}}
|
|
391
|
+
reply = bind_session(binding, sid)
|
|
392
|
+
_dm(binding["open_id"], reply, cfg) # 完整回执走私聊(toast 只做轻提示)
|
|
393
|
+
if reply == SESSION_GONE:
|
|
394
|
+
return {"toast": {"type": "error", "content": "会话已不存在,发「会话」刷新列表"}}
|
|
395
|
+
if reply in SESSION_FAIL_TEXTS:
|
|
396
|
+
return {"toast": {"type": "error", "content": "绑定失败"}}
|
|
397
|
+
return {"toast": {"type": "success", "content": "已绑定该会话"}}
|
|
398
|
+
return None # 未知 t:交给飞书卡片回调兜底(不更新卡片)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _selected_project_id(action):
|
|
402
|
+
"""下拉回调里被选中的项目 id:`action.option`(str 或 list,取首个)→ 兜底
|
|
403
|
+
`action.value.i`;非数字返回 0(0 不是合法项目 id,定位必然失败 ⇒ 错误 toast)。"""
|
|
404
|
+
raw = (action or {}).get("option")
|
|
405
|
+
if isinstance(raw, (list, tuple)):
|
|
406
|
+
raw = raw[0] if raw else ""
|
|
407
|
+
if raw in (None, ""):
|
|
408
|
+
raw = ((action or {}).get("value") or {}).get("i")
|
|
409
|
+
try:
|
|
410
|
+
return int(str(raw).strip())
|
|
411
|
+
except (TypeError, ValueError):
|
|
412
|
+
return 0
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _selected_session(action):
|
|
416
|
+
"""下拉回调里被选中的会话 id:`action.option`(str 或 list,取首个)→ 兜底
|
|
417
|
+
`action.value.i`;取不到返回 ""(回调据此回错误 toast,绝不无依据地绑定)。"""
|
|
418
|
+
raw = (action or {}).get("option")
|
|
419
|
+
if isinstance(raw, (list, tuple)):
|
|
420
|
+
raw = raw[0] if raw else ""
|
|
421
|
+
if raw in (None, ""):
|
|
422
|
+
raw = ((action or {}).get("value") or {}).get("i")
|
|
423
|
+
return str(raw or "").strip()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ---------- 会话列表卡、绑定 / 解绑与文本兜底(Task 5) ----------
|
|
427
|
+
#
|
|
428
|
+
# 卡片形态(设计 §3.7):默认项目下最近活跃的 SESSIONS_MAX(10) 个会话各一块 markdown
|
|
429
|
+
# (`**序号. 名称** · 相对时间 · 状态标`;序号供「绑定会话 <序号>」文本兜底)+ **唯一**
|
|
430
|
+
# 根级 `select_static`(option.value = 会话 id),点选即回调 `t=ss` 绑定。零会话时不发
|
|
431
|
+
# 控件、只发一块引导 markdown(空下拉点不出东西)。看卡**只读**:绑定态只由显式写口改。
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def sessions_card(binding):
|
|
435
|
+
"""会话列表卡(`会话` 指令):无默认项目返回 None(调用方回「先设默认项目」文本);
|
|
436
|
+
零会话返回**有卡片无控件**(一块引导 markdown)。
|
|
437
|
+
|
|
438
|
+
数据源 `sessparse.list_sessions("dsh", project["project_dir"])`(已按 mtime 倒序),
|
|
439
|
+
取前 SESSIONS_MAX 条;同时把本次顺序登记进 `_LAST_LIST[open_id]`(「绑定会话
|
|
440
|
+
<序号>」兜底),登记条数与卡片实际列出的条数**始终一致**(体积降级后同步收缩,
|
|
441
|
+
防序号错位)。体积策略同 `projects_card`:超 CARD_MAX_BYTES 逐次折半展示条数
|
|
442
|
+
(至少留 1 条)——飞书对超限卡片整条拒收,宁可少列也不能发不出去。
|
|
443
|
+
"""
|
|
444
|
+
project = _default_project(binding)
|
|
445
|
+
if project is None:
|
|
446
|
+
return None
|
|
447
|
+
items = sessparse.list_sessions("dsh", project["project_dir"])[:SESSIONS_MAX]
|
|
448
|
+
open_id = binding["open_id"]
|
|
449
|
+
header = {"title": {"tag": "plain_text",
|
|
450
|
+
"content": f"会话列表(默认:{_clip_name(project['name'])})"},
|
|
451
|
+
"template": "blue"}
|
|
452
|
+
if not items:
|
|
453
|
+
_remember_list(open_id, []) # 空列表也登记(序号必然越界)
|
|
454
|
+
return _card(header, [{"tag": "markdown", "content": SESSION_EMPTY}])
|
|
455
|
+
cur_sid = str(_binding_value(binding, "cur_sid", "") or "").strip()
|
|
456
|
+
# 行内容(含 🏃 运行标记)只算一次:体积降级只裁条数,不重复读会话状态
|
|
457
|
+
# (标记读本地 dshevents 注册表,零请求;未知/未连接一律不标)
|
|
458
|
+
rows = [_session_row(i, it, cur_sid) for i, it in enumerate(items, 1)]
|
|
459
|
+
shown = len(items)
|
|
460
|
+
card = _card(header, _sessions_elements(rows, items, shown, binding["user_id"]))
|
|
461
|
+
limit = shown // 2
|
|
462
|
+
while _card_bytes(card) > CARD_MAX_BYTES and shown > 1:
|
|
463
|
+
shown = min(shown, limit)
|
|
464
|
+
card = _card(header, _sessions_elements(rows, items, shown, binding["user_id"]))
|
|
465
|
+
limit = max(1, limit // 2)
|
|
466
|
+
_remember_list(open_id, [it["sid"] for it in items[:shown]])
|
|
467
|
+
return card
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _sessions_elements(rows, items, shown, user_id):
|
|
471
|
+
"""列表卡根级元素:前 shown 条各一块 markdown(rows 已渲染好)+ 唯一的根级下拉
|
|
472
|
+
(option.value = 会话 id,behaviors 带 `t=ss` 与点击者 user_id 供防串号复核)。"""
|
|
473
|
+
els = list(rows[:shown])
|
|
474
|
+
els.append({"tag": "select_static", "name": "ts_sessions",
|
|
475
|
+
"placeholder": {"tag": "plain_text", "content": "选择要绑定的会话"},
|
|
476
|
+
"options": [{"text": {"tag": "plain_text",
|
|
477
|
+
"content": _session_name(it)[:24]},
|
|
478
|
+
"value": str(it["sid"])} for it in items[:shown]],
|
|
479
|
+
"behaviors": [{"type": "callback",
|
|
480
|
+
"value": {"t": "ss", "u": user_id}}]})
|
|
481
|
+
return els
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _session_row(index, item, cur_sid):
|
|
485
|
+
"""单行 markdown:`**序号. 名称** · 相对时间 · 状态标`。
|
|
486
|
+
|
|
487
|
+
状态标:🏃 运行中(`_session_busy`)、📦 已归档(`item["archived"]`)、✅ 当前
|
|
488
|
+
(sid == 已绑定的 cur_sid)。序号是「绑定会话 <序号>」的锚,必须与实际列出顺序一致。
|
|
489
|
+
"""
|
|
490
|
+
marks = []
|
|
491
|
+
if _session_busy(item["sid"]):
|
|
492
|
+
marks.append("🏃 运行中")
|
|
493
|
+
if item.get("archived"):
|
|
494
|
+
marks.append("📦 已归档")
|
|
495
|
+
if str(item["sid"]) == str(cur_sid or ""):
|
|
496
|
+
marks.append("✅ 当前")
|
|
497
|
+
return {"tag": "markdown",
|
|
498
|
+
"content": "**%d. %s** · %s%s" % (index, _session_name(item),
|
|
499
|
+
_rel_time(item.get("mtime")),
|
|
500
|
+
(" · " + " ".join(marks)) if marks else "")}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _session_name(item):
|
|
504
|
+
"""会话展示名(fallback 链:标题 → 首问首行 → sid 短码),按 SESSION_NAME_MAX 截断。"""
|
|
505
|
+
name = str(item.get("title") or "").strip()
|
|
506
|
+
if not name:
|
|
507
|
+
prompt = str(item.get("first_prompt") or "").strip()
|
|
508
|
+
name = prompt.splitlines()[0].strip() if prompt else ""
|
|
509
|
+
if not name:
|
|
510
|
+
name = str(item.get("sid") or "")[:12]
|
|
511
|
+
return name[:SESSION_NAME_MAX]
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _sessions_text(binding):
|
|
515
|
+
"""列表卡的**纯文本兜底**(卡片未被受理时):会话清单 + 文本绑定指引。
|
|
516
|
+
|
|
517
|
+
序号取自 `_LAST_LIST[open_id]`(`sessions_card` 刚登记的那份:与卡片下拉严格同序,
|
|
518
|
+
且已随体积降级同步收缩)——这样文本里的「1.」就是「绑定会话 1」能绑的那条,照文本
|
|
519
|
+
发序号不会绑错。标题按 sid 从当前会话集回查;列表记忆缺失(理论不可达,
|
|
520
|
+
`sessions_card` 两条分支都登记)才现取一份。零会话回 `SESSION_EMPTY`(它本身已给出
|
|
521
|
+
「直接发一句话 / 新建会话」的出路)。
|
|
522
|
+
"""
|
|
523
|
+
entry = _LAST_LIST.get(binding["open_id"])
|
|
524
|
+
if entry:
|
|
525
|
+
sids = [str(s) for s in entry[1]]
|
|
526
|
+
else:
|
|
527
|
+
sids = [str(it["sid"]) for it in _project_sessions(binding)[:SESSIONS_MAX]]
|
|
528
|
+
if not sids:
|
|
529
|
+
return SESSION_EMPTY
|
|
530
|
+
by_sid = {str(it["sid"]): it for it in _project_sessions(binding)}
|
|
531
|
+
project = _default_project(binding)
|
|
532
|
+
lines = [(f"「{_clip_name(project['name'])}」最近会话:" if project else "最近会话:")]
|
|
533
|
+
for i, sid in enumerate(sids, 1):
|
|
534
|
+
item = by_sid.get(sid) or {"sid": sid}
|
|
535
|
+
lines.append(f"{i}. {_session_name(item)}({sid[:12]})")
|
|
536
|
+
lines.append(SESSIONS_TEXT_HINT)
|
|
537
|
+
return "\n".join(lines)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _remember_list(open_id, sids):
|
|
541
|
+
"""登记本次列表卡的会话顺序(`绑定会话 <序号>` 兜底用);写前清理过期桶。"""
|
|
542
|
+
now = time.time()
|
|
543
|
+
for key in [k for k, v in list(_LAST_LIST.items()) if now - v[0] > LIST_TTL]:
|
|
544
|
+
_LAST_LIST.pop(key, None)
|
|
545
|
+
_LAST_LIST[open_id] = (now, [str(s) for s in sids])
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _session_busy(sid):
|
|
549
|
+
"""会话是否正在跑 turn(True ⇒ 列表卡显示 🏃;仅展示用)。
|
|
550
|
+
|
|
551
|
+
读口=`dshevents` 的进程内注册表(状态由 dsh 宿主 `agent/status` 事件**推**来,
|
|
552
|
+
**零请求**):列表卡在飞书入站线程里同步构建,若逐会话打驱动 `/status`
|
|
553
|
+
(15s 超时 × 最多 SESSIONS_MAX 条)会把入站线程整条卡住。未连接中枢或未见过
|
|
554
|
+
该会话时 `get` 返回 None ⇒ **不标**(未知 ≠ 空闲,不替宿主下忙/闲断言);
|
|
555
|
+
任何异常同样按「不标」处理——忙闲探测失败绝不能打断列表卡渲染。
|
|
556
|
+
"""
|
|
557
|
+
try:
|
|
558
|
+
st = dshevents.get(sid)
|
|
559
|
+
return bool(st) and st.get("status") == "running"
|
|
560
|
+
except Exception as e:
|
|
561
|
+
_trace(f"会话忙闲探测失败 sid={str(sid)[:12]}: {e!r}")
|
|
562
|
+
return False
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _rel_time(mtime):
|
|
566
|
+
"""相对活跃时间:刚刚 / N 分钟前 / N 小时前 / N 天前(脏值/未来/缺值一律「刚刚」)。"""
|
|
567
|
+
try:
|
|
568
|
+
ts = float(mtime)
|
|
569
|
+
except (TypeError, ValueError):
|
|
570
|
+
return "刚刚"
|
|
571
|
+
if ts <= 0:
|
|
572
|
+
return "刚刚"
|
|
573
|
+
delta = time.time() - ts
|
|
574
|
+
if delta < 60:
|
|
575
|
+
return "刚刚"
|
|
576
|
+
if delta < 3600:
|
|
577
|
+
return "%d 分钟前" % int(delta // 60)
|
|
578
|
+
if delta < 86400:
|
|
579
|
+
return "%d 小时前" % int(delta // 3600)
|
|
580
|
+
return "%d 天前" % int(delta // 86400)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _session_project(binding):
|
|
584
|
+
"""会话绑定用到的项目:默认项目 → 当前会话所属项目(按 cur_project_id 还原)→ None。
|
|
585
|
+
|
|
586
|
+
默认项目缺失(未设/项目被删)时按 `cur_project_id` 还原,是为了让「重新绑定当前
|
|
587
|
+
会话所在项目里的另一个会话」在默认项目悬空时仍可用;两处都过 `_project_by_id`,
|
|
588
|
+
非本人项目一律 None(多用户隔离)。
|
|
589
|
+
"""
|
|
590
|
+
project = _default_project(binding)
|
|
591
|
+
if project is None:
|
|
592
|
+
project = _project_by_id(binding, _binding_value(binding, "cur_project_id", 0))
|
|
593
|
+
return project
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _project_sessions(binding):
|
|
597
|
+
"""`_session_project` 下的会话列表(mtime 倒序,上限由 sessparse 定 50);
|
|
598
|
+
项目缺失 / 读不到返回 [](列表是增强能力,绝不抛异常)。同时是 `bind_session`
|
|
599
|
+
的归属校验数据源:sid ∈ 该结果集才算「属于这个项目」(见 bind_session)。"""
|
|
600
|
+
project = _session_project(binding)
|
|
601
|
+
if project is None:
|
|
602
|
+
return []
|
|
603
|
+
return sessparse.list_sessions("dsh", project["project_dir"])
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def bind_session(binding, sid, title=""):
|
|
607
|
+
"""绑定会话(`t=ss` 回调与「绑定会话 …」文本兜底共用):返回回执文案。
|
|
608
|
+
|
|
609
|
+
**所有「选中一个 sid 写绑定」的路径都必须走这里**(`on_card_action` 的 `t=ss`
|
|
610
|
+
分支、`use_by_token` 的序号/标题/sid 前缀三路),写前做一次项目归属复验:
|
|
611
|
+
sid 必须落在**该项目 `project_dir` 的会话桶**内,判据是 `sessparse.list_sessions`
|
|
612
|
+
的结果集(**不拿 sid 直接开文件**——`session_exists` 是跨 bucket 的纯路径存在性,
|
|
613
|
+
验不出「这个 sid 属不属于当前项目」)。`list_sessions` 一次枚举同时覆盖了
|
|
614
|
+
「会话已被删除/归档清理」与「卡是别的项目的旧快照」两种失效(卡片下发后用户换过
|
|
615
|
+
默认项目时,旧卡的 option value 仍指向旧项目的 sid);复验不过**一个字都不写**
|
|
616
|
+
——否则会写出 `cur_sid` 与 `cur_project_id` 不一致的绑定态,让别项目的会话占用
|
|
617
|
+
本项目的运行位、答复回流也归错项目。
|
|
618
|
+
|
|
619
|
+
唯一不经此处的写路径是**新建**会话(`_create_in_project`):sid 由驱动在
|
|
620
|
+
`project_dir` 下现建,天然属于本项目,且刚建的会话未必已进入列表快照。
|
|
621
|
+
`title` 缺省现取会话标题(取不到回落 sid 前 12 位展示)。返回文案命中
|
|
622
|
+
`SESSION_FAIL_TEXTS` 即表示**没写绑定**(回调据此选 toast)。
|
|
623
|
+
"""
|
|
624
|
+
sid = str(sid or "").strip()
|
|
625
|
+
project = _session_project(binding)
|
|
626
|
+
if project is None:
|
|
627
|
+
return NO_DEFAULT_GUIDE
|
|
628
|
+
if sid not in {str(it["sid"]) for it in _project_sessions(binding)}:
|
|
629
|
+
return SESSION_GONE
|
|
630
|
+
title = str(title or "").strip() or sessparse.session_title("dsh", sid)
|
|
631
|
+
db.set_feishu_cur_session(binding["open_id"], project["id"], sid, title)
|
|
632
|
+
return (f"已绑定「{title or sid[:12]}」(项目:{project['name']}),"
|
|
633
|
+
f"接下来发消息都进这个会话")
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def use_by_token(binding, token):
|
|
637
|
+
"""文本兜底绑定(「绑定会话 <序号|标题关键字|会话id前缀>」):返回回执文案。
|
|
638
|
+
|
|
639
|
+
三分支(设计 §3.7):① 纯数字 ⇒ 上一张列表卡的序号(`_LAST_LIST`,TTL LIST_TTL;
|
|
640
|
+
过期/越界只回引导,绝不瞎猜「第 1 条」);② 标题包含**唯一**命中 ⇒ 绑定,多命中
|
|
641
|
+
回候选列表(不猜);③ sid 前缀唯一命中 ⇒ 绑定,多命中同样回候选。都不中 ⇒ 引导
|
|
642
|
+
发「会话」看最近 10 条。
|
|
643
|
+
"""
|
|
644
|
+
token = str(token or "").strip()
|
|
645
|
+
if not token:
|
|
646
|
+
return SESSION_STALE
|
|
647
|
+
if token.isdigit():
|
|
648
|
+
entry = _LAST_LIST.get(binding["open_id"])
|
|
649
|
+
idx = int(token) - 1
|
|
650
|
+
if not entry or (time.time() - entry[0]) > LIST_TTL or not 0 <= idx < len(entry[1]):
|
|
651
|
+
return SESSION_STALE
|
|
652
|
+
return bind_session(binding, entry[1][idx])
|
|
653
|
+
items = _project_sessions(binding)
|
|
654
|
+
hits = [it for it in items if token in str(it.get("title") or "")]
|
|
655
|
+
if not hits:
|
|
656
|
+
hits = [it for it in items if str(it.get("sid") or "").startswith(token)]
|
|
657
|
+
if len(hits) == 1:
|
|
658
|
+
return bind_session(binding, hits[0]["sid"], hits[0].get("title") or "")
|
|
659
|
+
if len(hits) > 1:
|
|
660
|
+
return SESSION_MULTI_FMT % (token, "\n".join(
|
|
661
|
+
f"· {_session_name(it)}({str(it['sid'])[:12]})" for it in hits[:8]))
|
|
662
|
+
return SESSION_MISS_FMT % token
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def leave_session(binding):
|
|
666
|
+
"""「解绑会话」:只清当前会话绑定(默认项目原样保留);未绑定时给明确回执。"""
|
|
667
|
+
sid = str(_binding_value(binding, "cur_sid", "") or "").strip()
|
|
668
|
+
if not sid:
|
|
669
|
+
return "当前未绑定会话"
|
|
670
|
+
project = _default_project(binding)
|
|
671
|
+
name = project["name"] if project is not None else "未设置"
|
|
672
|
+
db.clear_feishu_cur_session(binding["open_id"])
|
|
673
|
+
return f"已解绑当前会话(默认项目保持为「{name}」),下次发消息将自动新建"
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def send_card(open_id, card, cfg):
|
|
677
|
+
"""给绑定用户发交互卡片(`项目`/`会话` 指令用):懒 import feishu 后走
|
|
678
|
+
`rest_send_card`;凭据不全只留痕返回 False(与 `_dm` 同闸——绝不用空凭据去打
|
|
679
|
+
真实接口),异常同样只留痕返回 False(卡片发不出不该让指令执行本身失败)。
|
|
680
|
+
|
|
681
|
+
返回 True=飞书已受理。调用方(`conv_intent`)据此决定是否补发**文本兜底**:
|
|
682
|
+
「卡片即回执」的成功路径不另发文本,可一旦卡片没被受理,那条路径就会变成
|
|
683
|
+
「用户发了指令却一个字都收不到」——故发送结果必须回传(模块先例:
|
|
684
|
+
`send_slash_perm_card` 返回 `(ok, err)`)。
|
|
685
|
+
"""
|
|
686
|
+
import feishu # 函数内懒 import(feishu → runner → chat 已成环)
|
|
687
|
+
if not _has_creds(cfg):
|
|
688
|
+
_trace(f"卡片跳过(应用凭据缺失)open_id={str(open_id)[:12]}")
|
|
689
|
+
return False
|
|
690
|
+
try:
|
|
691
|
+
feishu.rest_send_card(open_id, card, cfg)
|
|
692
|
+
except Exception as e:
|
|
693
|
+
_trace(f"卡片发送失败: {e!r}")
|
|
694
|
+
return False
|
|
695
|
+
return True
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _dm(open_id, text, cfg=None, user_id=0):
|
|
699
|
+
"""给绑定用户发 DM 文本(卡片回执 / Task 6 答复回流共用)。
|
|
700
|
+
|
|
701
|
+
**凭据前置闸**:`cfg` 缺 `app_id` 或 `app_secret` 时只留痕返回,绝不发网络
|
|
702
|
+
请求——卡片回调与回流的单测都会走到这里(控制者裁决 F1:缺闸就会去打真实
|
|
703
|
+
飞书接口)。
|
|
704
|
+
"""
|
|
705
|
+
import feishu # 函数内懒 import(同上)
|
|
706
|
+
cfg = cfg or feishu.app_config(user_id or 0)
|
|
707
|
+
if not _has_creds(cfg):
|
|
708
|
+
_trace(f"DM 跳过(应用凭据缺失)open_id={str(open_id)[:12]}")
|
|
709
|
+
return
|
|
710
|
+
try:
|
|
711
|
+
feishu.rest_send_text(open_id, text, cfg)
|
|
712
|
+
except Exception as e:
|
|
713
|
+
_trace(f"DM 发送失败: {e!r}")
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _has_creds(cfg):
|
|
717
|
+
"""应用凭据是否齐备(app_id + app_secret):卡片发送与 DM 回执共用的前置闸。"""
|
|
718
|
+
return bool(cfg) and bool(cfg.get("app_id")) and bool(cfg.get("app_secret"))
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _clip_name(name):
|
|
722
|
+
"""项目名展示截断(卡片体积兜底;正常项目名远短于此,仅在异常长名时生效)。"""
|
|
723
|
+
return str(name or "")[:PROJECT_NAME_MAX]
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _binding_value(binding, key, default=""):
|
|
727
|
+
"""binding 字段容错读取:生产是 sqlite3.Row、用例里可能是普通 dict,少键一律按
|
|
728
|
+
缺省值处理(直接下标遇少键会 KeyError,被上层 except 吞成「静默无回复」)。"""
|
|
729
|
+
try:
|
|
730
|
+
return binding[key]
|
|
731
|
+
except (KeyError, IndexError, TypeError):
|
|
732
|
+
return default
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _projects_of(binding):
|
|
736
|
+
"""该绑定用户的全部项目(多用户隔离:只查本人)。"""
|
|
737
|
+
return db.list_projects(binding["user_id"])
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _project_by_id(binding, pid):
|
|
741
|
+
"""按 id 在本人的项目里定位(不存在/非本人项目一律 None,防越权)。"""
|
|
742
|
+
try:
|
|
743
|
+
pid = int(pid or 0)
|
|
744
|
+
except (TypeError, ValueError):
|
|
745
|
+
return None
|
|
746
|
+
for p in _projects_of(binding):
|
|
747
|
+
if p["id"] == pid:
|
|
748
|
+
return p
|
|
749
|
+
return None
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _default_project(binding):
|
|
753
|
+
"""单聊默认项目行(未设或项目已删返回 None)。"""
|
|
754
|
+
return _project_by_id(binding, binding["default_project_id"])
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _locate_project(binding):
|
|
758
|
+
"""项目定位(口径同 feishu._resolve_project):默认项目 → 用户唯一项目 →
|
|
759
|
+
(None, 错误文案);报错分支列出可选项目名。返回 (project, "") 或 (None, 文案)。"""
|
|
760
|
+
project = _default_project(binding)
|
|
761
|
+
if project is not None:
|
|
762
|
+
return project, ""
|
|
763
|
+
owned = _projects_of(binding)
|
|
764
|
+
if len(owned) == 1:
|
|
765
|
+
return owned[0], ""
|
|
766
|
+
if not owned:
|
|
767
|
+
return None, NO_PROJECTS_GUIDE
|
|
768
|
+
return None, ("请指明项目(回复「默认项目 <项目名>」),可选:" +
|
|
769
|
+
"、".join(p["name"] for p in owned[:8]))
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _create_in_project(binding, project, title):
|
|
773
|
+
"""在项目目录下新建 dsh 会话并写「当前会话」绑定。返回 (sid, err),err 非空=失败。
|
|
774
|
+
|
|
775
|
+
步骤:驱动可用性闸(中文引导,绝不抛异常栈)→ `create_session` → 项目级会话
|
|
776
|
+
默认值(模型 / 思考等级 / 权限档,**best-effort 只留痕不阻断**)→ `rename`
|
|
777
|
+
(同上)→ `db.set_feishu_cur_session`。失败路径一律不写绑定。
|
|
778
|
+
"""
|
|
779
|
+
if not dshdriver.configured():
|
|
780
|
+
return "", DRIVER_NEEDED
|
|
781
|
+
title = (title or "").strip() or _new_title()
|
|
782
|
+
# 项目级模型必须在**建会话时**下传(口径同 board._start_web):宿主 `/session`
|
|
783
|
+
# 不拆 `provider/id` 前缀,故先 split_model 拆成 provider + 裸 id;
|
|
784
|
+
# 只喂 apply_session_defaults 会漏——它仅在 reasoning_effort 非空时才调
|
|
785
|
+
# set_model(dshdriver.py:420-429),只配模型的项目会静默跑在宿主默认模型上。
|
|
786
|
+
model_provider, model_id = dshdriver.split_model(str(project["model"] or ""))
|
|
787
|
+
try:
|
|
788
|
+
sid = dshdriver.create_session(cwd=project["project_dir"], task=title,
|
|
789
|
+
model=model_id, provider=model_provider)
|
|
790
|
+
except Exception as e:
|
|
791
|
+
_trace(f"新建会话失败 project={project['id']}: {e!r}")
|
|
792
|
+
return "", f"新建会话失败:{e}"
|
|
793
|
+
try:
|
|
794
|
+
dshdriver.apply_session_defaults(
|
|
795
|
+
sid, model=model_id, provider=model_provider,
|
|
796
|
+
reasoning_effort=str(project["reasoning_effort"] or "").strip(),
|
|
797
|
+
permission_mode=str(project["permission_mode"] or "").strip(),
|
|
798
|
+
log=_trace)
|
|
799
|
+
except Exception as e:
|
|
800
|
+
_trace(f"会话默认值下发失败 sid={sid}: {e!r}") # 只留痕,不阻断
|
|
801
|
+
try:
|
|
802
|
+
dshdriver.rename(sid, title)
|
|
803
|
+
except Exception as e:
|
|
804
|
+
_trace(f"会话改名失败 sid={sid}: {e!r}") # 只留痕,不阻断
|
|
805
|
+
db.set_feishu_cur_session(binding["open_id"], project["id"], sid, title)
|
|
806
|
+
return sid, ""
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _baseline_of(sid):
|
|
810
|
+
"""投递前条目基线:回流按 `after=baseline` 只取本轮新增(异常回落 0)。"""
|
|
811
|
+
try:
|
|
812
|
+
return int(sessparse.load("dsh", sid, "main", after=0).get("total", 0) or 0)
|
|
813
|
+
except Exception:
|
|
814
|
+
return 0
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
# ---------- Task 6:答复回流(终态钩子 → 抽取新增 assistant 文本 → DM 推送) ----------
|
|
818
|
+
#
|
|
819
|
+
# 链路:chat.run_unit 收口 → chat._fire_msg_done(msg_id, state) → 本模块
|
|
820
|
+
# notify_msg_done → waitq.msg_meta 取提交时定格的 feishu 载荷(open_id/baseline)
|
|
821
|
+
# → sessparse.load(after=baseline) 抽本轮 assistant 文本 → _clip_reply 截断 → _dm。
|
|
822
|
+
# 载荷只在等待项行上(chat_msgs 无 meta 列),且要读**终态之后**的行 ⇒ 走
|
|
823
|
+
# waitq.msg_meta 的 state 无关口径。
|
|
824
|
+
|
|
825
|
+
def start():
|
|
826
|
+
"""注册消息终态钩子(feishu.start_notifier 调用;幂等)。
|
|
827
|
+
|
|
828
|
+
钩子是**单一注册位**(chat.set_msg_done_hook 后注册覆盖先注册),重复调用
|
|
829
|
+
等价于再注册一次同一个函数——无副作用、不叠加。
|
|
830
|
+
"""
|
|
831
|
+
import chat # 函数内 import 防环
|
|
832
|
+
chat.set_msg_done_hook(notify_msg_done)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def notify_msg_done(msg_id, state):
|
|
836
|
+
"""消息单元终态 → 把本轮答复推回飞书(非飞书来源直接返回)。
|
|
837
|
+
|
|
838
|
+
state 三态:yielded=turn 挂起等作答,**不推**(作答链路自己会回卡/回执,
|
|
839
|
+
再推一份答复只会与它重复且此刻根本没有新答复);error=推失败回执(带
|
|
840
|
+
chat_msgs.error 摘要);done=抽 baseline 之后的新增 assistant 文本推出。
|
|
841
|
+
任何异常都不外抛(钩子是旁路,队列收口不因推送失败受影响)。
|
|
842
|
+
"""
|
|
843
|
+
import chat, waitq
|
|
844
|
+
meta = (waitq.msg_meta(msg_id) or {}).get("feishu") or {}
|
|
845
|
+
if not meta:
|
|
846
|
+
return
|
|
847
|
+
open_id = meta.get("open_id") or ""
|
|
848
|
+
if not open_id:
|
|
849
|
+
return
|
|
850
|
+
if not _binding_current(open_id, meta.get("user_id")):
|
|
851
|
+
_trace(f"答复跳过(绑定已变更)open_id={str(open_id)[:12]}")
|
|
852
|
+
return
|
|
853
|
+
if state == "yielded":
|
|
854
|
+
print(f"[feishu_conv] 会话挂起等作答,不推答复:{msg_id}", flush=True)
|
|
855
|
+
return
|
|
856
|
+
if state == "error":
|
|
857
|
+
row = waitq.msg_get(msg_id)
|
|
858
|
+
err = ((row["error"] if row is not None else "") or "")[:200]
|
|
859
|
+
_dm(open_id, f"本轮执行失败:{err or '未知原因'}(可在站点会话窗查看详情)",
|
|
860
|
+
None, meta.get("user_id"))
|
|
861
|
+
return
|
|
862
|
+
row = waitq.msg_get(msg_id)
|
|
863
|
+
sid = (row["sid"] if row is not None else "") or ""
|
|
864
|
+
reply = _clip_reply(_reply_of(sid, int(meta.get("baseline") or 0)))
|
|
865
|
+
if reply:
|
|
866
|
+
_dm(open_id, reply, None, meta.get("user_id"))
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _binding_current(open_id, user_id):
|
|
870
|
+
"""推送前的绑定归属复核:open_id 当前仍有绑定行、且其 `user_id` 与载荷一致。
|
|
871
|
+
|
|
872
|
+
载荷里的 `open_id` 是**提交那一刻**定格的;用户提交后可能在设置页解绑,或把同一
|
|
873
|
+
个飞书号改绑给另一个 Touchstone 用户(`db.set_feishu_binding` 按 open_id UPSERT)。
|
|
874
|
+
答复含会话正文,只能送给提交者本人——不一致就不推(否则会送到接手该飞书号的
|
|
875
|
+
新持有人手里)。库读写异常一律按「复核不过」处理:宁可不推,绝不误推。
|
|
876
|
+
"""
|
|
877
|
+
try:
|
|
878
|
+
row = db.get_feishu_binding_by_open(open_id)
|
|
879
|
+
except Exception as e:
|
|
880
|
+
_trace(f"绑定复核失败 open_id={str(open_id)[:12]}: {e!r}")
|
|
881
|
+
return False
|
|
882
|
+
if row is None:
|
|
883
|
+
return False
|
|
884
|
+
return str(row["user_id"]) == str(user_id)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _reply_of(sid, baseline):
|
|
888
|
+
"""取该会话 baseline 之后的新增 assistant 文本(think/tool_* 忽略;异常返回空串)。
|
|
889
|
+
|
|
890
|
+
baseline 是投递**前**的条目总数(route_text 在 submit 前定格):`after=baseline`
|
|
891
|
+
的切片即本轮新增(含 user/think/tool_call/tool_result/assistant 五类),这里只
|
|
892
|
+
拼 assistant(think 是模型的草稿、tool_* 是过程,都不是给用户的答复)。
|
|
893
|
+
"""
|
|
894
|
+
try:
|
|
895
|
+
data = sessparse.load("dsh", sid, "main", after=baseline)
|
|
896
|
+
except Exception:
|
|
897
|
+
return ""
|
|
898
|
+
if not data.get("found"):
|
|
899
|
+
return ""
|
|
900
|
+
parts = [e.get("text") or "" for e in data.get("entries") or []
|
|
901
|
+
if e.get("kind") == "assistant"]
|
|
902
|
+
return "\n\n".join(p for p in parts if p.strip())
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _clip_reply(text):
|
|
906
|
+
"""答复截断:超 REPLY_MAX_CHARS 保留首段并附站点指引(飞书单条消息不宜过长)。"""
|
|
907
|
+
text = (text or "").strip()
|
|
908
|
+
if len(text) <= REPLY_MAX_CHARS:
|
|
909
|
+
return text
|
|
910
|
+
return text[:REPLY_MAX_CHARS - 200] + "\n……(内容过长已截断,完整内容见站点会话窗 / 看板)"
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _new_title():
|
|
914
|
+
"""新建会话的缺省标题(本地时间;与 dsh 侧栏 / 会话列表卡里可辨认)。"""
|
|
915
|
+
return time.strftime(TITLE_FMT)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _trace(text):
|
|
919
|
+
"""轻量留痕(本层没有会话日志文件可写;与 feishu.py 的 stderr 诊断同风格)。"""
|
|
920
|
+
print(f"feishu_conv: {text}", file=sys.stderr, flush=True)
|