dfcode-remote 0.1.0__py3-none-any.whl
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.
- dfcode_remote/__init__.py +3 -0
- dfcode_remote/__main__.py +6 -0
- dfcode_remote/cli.py +106 -0
- dfcode_remote/dfcode_client/__init__.py +4 -0
- dfcode_remote/dfcode_client/client.py +196 -0
- dfcode_remote/dfcode_client/sse.py +105 -0
- dfcode_remote/dfcode_client/types.py +213 -0
- dfcode_remote/lark_client/__init__.py +19 -0
- dfcode_remote/lark_client/bot.py +73 -0
- dfcode_remote/lark_client/card_builder.py +467 -0
- dfcode_remote/lark_client/card_service.py +256 -0
- dfcode_remote/lark_client/event_listener.py +381 -0
- dfcode_remote/lark_client/handler.py +450 -0
- dfcode_remote/utils/__init__.py +4 -0
- dfcode_remote/utils/config.py +43 -0
- dfcode_remote/utils/markdown.py +53 -0
- dfcode_remote/utils/persistence.py +58 -0
- dfcode_remote-0.1.0.dist-info/METADATA +360 -0
- dfcode_remote-0.1.0.dist-info/RECORD +22 -0
- dfcode_remote-0.1.0.dist-info/WHEEL +5 -0
- dfcode_remote-0.1.0.dist-info/entry_points.txt +2 -0
- dfcode_remote-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Feishu WebSocket long-connection bootstrap.
|
|
2
|
+
|
|
3
|
+
Initialises the lark_oapi WebSocket client and registers event handlers.
|
|
4
|
+
|
|
5
|
+
The lark_oapi SDK runs its own internal event loop for WebSocket handling.
|
|
6
|
+
Our async handlers need to run on the main asyncio loop. We solve this by
|
|
7
|
+
capturing the main loop reference and using run_coroutine_threadsafe() to
|
|
8
|
+
dispatch coroutines from the SDK thread to the main loop.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Callable, Coroutine, Any
|
|
14
|
+
|
|
15
|
+
import lark_oapi as lark
|
|
16
|
+
from lark_oapi.api.im.v1.model.p2_im_message_receive_v1 import P2ImMessageReceiveV1
|
|
17
|
+
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
|
18
|
+
P2CardActionTrigger,
|
|
19
|
+
P2CardActionTriggerResponse,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
MessageHandler = Callable[[P2ImMessageReceiveV1], Coroutine[Any, Any, None]]
|
|
25
|
+
CardActionHandler = Callable[[P2CardActionTrigger], Coroutine[Any, Any, None]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def start_bot_async(
|
|
29
|
+
app_id: str,
|
|
30
|
+
app_secret: str,
|
|
31
|
+
on_message: MessageHandler,
|
|
32
|
+
on_card_action: CardActionHandler,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Start the Feishu WebSocket bot without blocking the main event loop.
|
|
35
|
+
|
|
36
|
+
Captures the running event loop, then launches the blocking SDK client
|
|
37
|
+
in a thread executor. SDK callbacks dispatch coroutines back to the
|
|
38
|
+
main loop via run_coroutine_threadsafe().
|
|
39
|
+
"""
|
|
40
|
+
main_loop = asyncio.get_running_loop()
|
|
41
|
+
|
|
42
|
+
def _sync_message(data: P2ImMessageReceiveV1) -> None:
|
|
43
|
+
try:
|
|
44
|
+
future = asyncio.run_coroutine_threadsafe(on_message(data), main_loop)
|
|
45
|
+
future.result(timeout=30)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.error("Error handling message: %s", e, exc_info=True)
|
|
48
|
+
|
|
49
|
+
def _sync_card(data: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
|
50
|
+
try:
|
|
51
|
+
logger.info("Received card action callback: %r", data)
|
|
52
|
+
future = asyncio.run_coroutine_threadsafe(on_card_action(data), main_loop)
|
|
53
|
+
future.result(timeout=30)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.error("Error handling card action: %s", e, exc_info=True)
|
|
56
|
+
return P2CardActionTriggerResponse()
|
|
57
|
+
|
|
58
|
+
handler = (
|
|
59
|
+
lark.EventDispatcherHandler.builder("", "")
|
|
60
|
+
.register_p2_im_message_receive_v1(_sync_message)
|
|
61
|
+
.register_p2_card_action_trigger(_sync_card)
|
|
62
|
+
.build()
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
cli = lark.ws.Client(
|
|
66
|
+
app_id,
|
|
67
|
+
app_secret,
|
|
68
|
+
log_level=lark.LogLevel.DEBUG,
|
|
69
|
+
event_handler=handler,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
logger.info("Starting Feishu WebSocket bot (app_id=%s)", app_id)
|
|
73
|
+
await main_loop.run_in_executor(None, cli.start)
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Pure card JSON builders for Feishu Card Schema 2.0.
|
|
2
|
+
|
|
3
|
+
No network calls — all functions take data and return dicts.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from dfcode_remote.dfcode_client.types import PartInfo
|
|
8
|
+
from dfcode_remote.utils.markdown import markdown_to_lark, format_tool_output
|
|
9
|
+
|
|
10
|
+
MAX_CARD_BLOCKS = 50
|
|
11
|
+
|
|
12
|
+
# Action payload keys
|
|
13
|
+
ACTION_TYPE_PERMISSION = "permission"
|
|
14
|
+
ACTION_TYPE_QUESTION = "question"
|
|
15
|
+
ACTION_TYPE_ABORT = "abort"
|
|
16
|
+
ACTION_TYPE_MENU = "menu"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _md_block(text: str) -> dict:
|
|
20
|
+
return {"tag": "markdown", "content": text}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _divider() -> dict:
|
|
24
|
+
return {"tag": "hr"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _note(text: str) -> dict:
|
|
28
|
+
"""Create a note-style text block using div instead of deprecated note tag."""
|
|
29
|
+
return {
|
|
30
|
+
"tag": "div",
|
|
31
|
+
"text": {"tag": "plain_text", "content": text},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _column_set(columns: list[dict]) -> dict:
|
|
36
|
+
return {"tag": "column_set", "flex_mode": "none", "columns": columns}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _column(elements: list[dict], weight: int = 1) -> dict:
|
|
40
|
+
return {
|
|
41
|
+
"tag": "column",
|
|
42
|
+
"width": "weighted",
|
|
43
|
+
"weight": weight,
|
|
44
|
+
"elements": elements,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _button(
|
|
49
|
+
label: str, value: dict, style: str = "default", disabled: bool = False
|
|
50
|
+
) -> dict:
|
|
51
|
+
return {
|
|
52
|
+
"tag": "button",
|
|
53
|
+
"text": {"tag": "plain_text", "content": label},
|
|
54
|
+
"type": style,
|
|
55
|
+
"value": value,
|
|
56
|
+
"disabled": disabled,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _collapsible_panel(title: str, content: str, expanded: bool = False) -> dict:
|
|
61
|
+
return {
|
|
62
|
+
"tag": "collapsible_panel",
|
|
63
|
+
"expanded": expanded,
|
|
64
|
+
"header": {
|
|
65
|
+
"title": {"tag": "plain_text", "content": title},
|
|
66
|
+
},
|
|
67
|
+
"elements": [_md_block(content)] if content else [],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _render_tool_part(part: PartInfo) -> dict:
|
|
72
|
+
state = part.state or {}
|
|
73
|
+
status = state.get("status", "running")
|
|
74
|
+
title_text = state.get("title", part.tool or "tool")
|
|
75
|
+
output = state.get("output", "")
|
|
76
|
+
|
|
77
|
+
if status == "completed":
|
|
78
|
+
icon = "🔧"
|
|
79
|
+
label = f"{icon} {part.tool} — {title_text}"
|
|
80
|
+
body = format_tool_output(output) if output else ""
|
|
81
|
+
return _collapsible_panel(label, body, expanded=False)
|
|
82
|
+
|
|
83
|
+
icon = "⏳"
|
|
84
|
+
label = f"{icon} {part.tool or 'tool'}"
|
|
85
|
+
return _collapsible_panel(label, "", expanded=False)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _render_step_finish(part: PartInfo) -> dict:
|
|
89
|
+
tokens = part.tokens or {}
|
|
90
|
+
cost = part.cost or {}
|
|
91
|
+
inp = tokens.get("input", 0)
|
|
92
|
+
out = tokens.get("output", 0)
|
|
93
|
+
total_cost = cost.get("total", 0)
|
|
94
|
+
line = f"tokens: {inp} in / {out} out"
|
|
95
|
+
if total_cost:
|
|
96
|
+
line += f" | cost: ${total_cost:.4f}"
|
|
97
|
+
return _note(line)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _content_blocks(parts: list[PartInfo]) -> list[dict]:
|
|
101
|
+
blocks: list[dict] = []
|
|
102
|
+
for part in parts:
|
|
103
|
+
if part.type == "text" and part.text:
|
|
104
|
+
blocks.append(_md_block(markdown_to_lark(part.text)))
|
|
105
|
+
elif part.type == "tool":
|
|
106
|
+
blocks.append(_render_tool_part(part))
|
|
107
|
+
elif part.type == "step-finish":
|
|
108
|
+
blocks.append(_render_step_finish(part))
|
|
109
|
+
elif part.type == "reasoning" and part.text:
|
|
110
|
+
blocks.append(
|
|
111
|
+
_collapsible_panel(
|
|
112
|
+
"💭 Reasoning", markdown_to_lark(part.text), expanded=False
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
return blocks
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _status_block(status: str) -> dict:
|
|
119
|
+
label = "⏳ 思考中..." if status == "busy" else "✅ 完成"
|
|
120
|
+
return {
|
|
121
|
+
"tag": "div",
|
|
122
|
+
"text": {"tag": "plain_text", "content": label},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _permission_buttons(permission: dict) -> list[dict]:
|
|
127
|
+
req_id = permission.get("id", "")
|
|
128
|
+
perm = permission.get("permission", "")
|
|
129
|
+
tool = permission.get("tool", "")
|
|
130
|
+
desc = f"🔐 {tool}: {perm}" if tool else f"🔐 {perm}"
|
|
131
|
+
return [
|
|
132
|
+
_md_block(desc),
|
|
133
|
+
_column_set(
|
|
134
|
+
[
|
|
135
|
+
_column(
|
|
136
|
+
[
|
|
137
|
+
_button(
|
|
138
|
+
"允许",
|
|
139
|
+
{
|
|
140
|
+
"type": ACTION_TYPE_PERMISSION,
|
|
141
|
+
"id": req_id,
|
|
142
|
+
"reply": "once",
|
|
143
|
+
},
|
|
144
|
+
style="primary",
|
|
145
|
+
)
|
|
146
|
+
]
|
|
147
|
+
),
|
|
148
|
+
_column(
|
|
149
|
+
[
|
|
150
|
+
_button(
|
|
151
|
+
"始终允许",
|
|
152
|
+
{
|
|
153
|
+
"type": ACTION_TYPE_PERMISSION,
|
|
154
|
+
"id": req_id,
|
|
155
|
+
"reply": "always",
|
|
156
|
+
},
|
|
157
|
+
style="default",
|
|
158
|
+
)
|
|
159
|
+
]
|
|
160
|
+
),
|
|
161
|
+
_column(
|
|
162
|
+
[
|
|
163
|
+
_button(
|
|
164
|
+
"拒绝",
|
|
165
|
+
{
|
|
166
|
+
"type": ACTION_TYPE_PERMISSION,
|
|
167
|
+
"id": req_id,
|
|
168
|
+
"reply": "reject",
|
|
169
|
+
},
|
|
170
|
+
style="danger",
|
|
171
|
+
)
|
|
172
|
+
]
|
|
173
|
+
),
|
|
174
|
+
]
|
|
175
|
+
),
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _question_buttons(question: dict) -> list[dict]:
|
|
180
|
+
req_id = question.get("id", "")
|
|
181
|
+
questions = question.get("questions", [])
|
|
182
|
+
blocks: list[dict] = []
|
|
183
|
+
for q in questions:
|
|
184
|
+
text = q.get("question", "")
|
|
185
|
+
options = q.get("options", [])
|
|
186
|
+
multiple = q.get("multiple", False)
|
|
187
|
+
custom = q.get("custom", True)
|
|
188
|
+
if text:
|
|
189
|
+
blocks.append(_md_block(f"❓ {text}"))
|
|
190
|
+
if not options:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
if multiple:
|
|
194
|
+
# Multi-select: dropdown + submit button in a form
|
|
195
|
+
select_options = []
|
|
196
|
+
for opt in options:
|
|
197
|
+
label = (
|
|
198
|
+
opt.get("label", str(opt)) if isinstance(opt, dict) else str(opt)
|
|
199
|
+
)
|
|
200
|
+
desc = opt.get("description", "") if isinstance(opt, dict) else ""
|
|
201
|
+
full_text = f"{label} — {desc}" if desc else label
|
|
202
|
+
select_options.append(
|
|
203
|
+
{
|
|
204
|
+
"text": {"tag": "plain_text", "content": full_text},
|
|
205
|
+
"value": label,
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
form_elements: list[dict] = [
|
|
209
|
+
{
|
|
210
|
+
"tag": "multi_select_static",
|
|
211
|
+
"name": "selected_options",
|
|
212
|
+
"placeholder": {
|
|
213
|
+
"tag": "plain_text",
|
|
214
|
+
"content": "选择一个或多个选项...",
|
|
215
|
+
},
|
|
216
|
+
"options": select_options,
|
|
217
|
+
"width": "fill",
|
|
218
|
+
},
|
|
219
|
+
]
|
|
220
|
+
if custom is not False:
|
|
221
|
+
form_elements.append(
|
|
222
|
+
{
|
|
223
|
+
"tag": "input",
|
|
224
|
+
"name": "custom_answer",
|
|
225
|
+
"placeholder": {
|
|
226
|
+
"tag": "plain_text",
|
|
227
|
+
"content": "或输入自定义回答...",
|
|
228
|
+
},
|
|
229
|
+
"width": "fill",
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
form_elements.append(
|
|
233
|
+
_column_set(
|
|
234
|
+
[
|
|
235
|
+
_column(
|
|
236
|
+
[
|
|
237
|
+
{
|
|
238
|
+
"tag": "button",
|
|
239
|
+
"name": "submit_multi",
|
|
240
|
+
"text": {
|
|
241
|
+
"tag": "plain_text",
|
|
242
|
+
"content": "确认提交",
|
|
243
|
+
},
|
|
244
|
+
"type": "primary",
|
|
245
|
+
"action_type": "form_submit",
|
|
246
|
+
"value": {
|
|
247
|
+
"type": ACTION_TYPE_QUESTION,
|
|
248
|
+
"id": req_id,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
]
|
|
252
|
+
),
|
|
253
|
+
]
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
blocks.append(
|
|
257
|
+
{
|
|
258
|
+
"tag": "form",
|
|
259
|
+
"name": f"question_form_{req_id}",
|
|
260
|
+
"elements": form_elements,
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
# Single-select: description as markdown, then button with short label
|
|
265
|
+
for opt in options:
|
|
266
|
+
label = (
|
|
267
|
+
opt.get("label", str(opt)) if isinstance(opt, dict) else str(opt)
|
|
268
|
+
)
|
|
269
|
+
desc = opt.get("description", "") if isinstance(opt, dict) else ""
|
|
270
|
+
if desc:
|
|
271
|
+
blocks.append(_md_block(f"**{label}**\n{desc}"))
|
|
272
|
+
blocks.append(
|
|
273
|
+
_column_set(
|
|
274
|
+
[
|
|
275
|
+
_column(
|
|
276
|
+
[
|
|
277
|
+
_button(
|
|
278
|
+
f"👉 {label}",
|
|
279
|
+
{
|
|
280
|
+
"type": ACTION_TYPE_QUESTION,
|
|
281
|
+
"id": req_id,
|
|
282
|
+
"answer": label,
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
]
|
|
286
|
+
)
|
|
287
|
+
]
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
else:
|
|
291
|
+
blocks.append(
|
|
292
|
+
_column_set(
|
|
293
|
+
[
|
|
294
|
+
_column(
|
|
295
|
+
[
|
|
296
|
+
_button(
|
|
297
|
+
label,
|
|
298
|
+
{
|
|
299
|
+
"type": ACTION_TYPE_QUESTION,
|
|
300
|
+
"id": req_id,
|
|
301
|
+
"answer": label,
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
]
|
|
305
|
+
)
|
|
306
|
+
]
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
# Custom text input for single-select
|
|
310
|
+
if custom is not False:
|
|
311
|
+
blocks.append(
|
|
312
|
+
{
|
|
313
|
+
"tag": "form",
|
|
314
|
+
"name": f"question_form_{req_id}",
|
|
315
|
+
"elements": [
|
|
316
|
+
{
|
|
317
|
+
"tag": "input",
|
|
318
|
+
"name": "custom_answer",
|
|
319
|
+
"placeholder": {
|
|
320
|
+
"tag": "plain_text",
|
|
321
|
+
"content": "或输入自定义回答...",
|
|
322
|
+
},
|
|
323
|
+
"width": "fill",
|
|
324
|
+
},
|
|
325
|
+
_column_set(
|
|
326
|
+
[
|
|
327
|
+
_column(
|
|
328
|
+
[
|
|
329
|
+
{
|
|
330
|
+
"tag": "button",
|
|
331
|
+
"name": "submit_custom",
|
|
332
|
+
"text": {
|
|
333
|
+
"tag": "plain_text",
|
|
334
|
+
"content": "提交自定义回答",
|
|
335
|
+
},
|
|
336
|
+
"type": "default",
|
|
337
|
+
"action_type": "form_submit",
|
|
338
|
+
"value": {
|
|
339
|
+
"type": ACTION_TYPE_QUESTION,
|
|
340
|
+
"id": req_id,
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
]
|
|
344
|
+
),
|
|
345
|
+
]
|
|
346
|
+
),
|
|
347
|
+
],
|
|
348
|
+
}
|
|
349
|
+
)
|
|
350
|
+
return blocks
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _abort_button(session_id: str) -> dict:
|
|
354
|
+
"""Create an abort button using column_set instead of deprecated action tag."""
|
|
355
|
+
return _column_set(
|
|
356
|
+
[
|
|
357
|
+
_column(
|
|
358
|
+
[
|
|
359
|
+
_button(
|
|
360
|
+
"⛔ 中止",
|
|
361
|
+
{"type": ACTION_TYPE_ABORT, "session_id": session_id},
|
|
362
|
+
style="danger",
|
|
363
|
+
)
|
|
364
|
+
]
|
|
365
|
+
),
|
|
366
|
+
]
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def build_streaming_card(
|
|
371
|
+
parts: list[PartInfo],
|
|
372
|
+
status: str = "busy",
|
|
373
|
+
permission: Optional[dict] = None,
|
|
374
|
+
question: Optional[dict] = None,
|
|
375
|
+
session_id: str = "",
|
|
376
|
+
) -> dict:
|
|
377
|
+
"""Build a live streaming card with content, status, and interaction areas."""
|
|
378
|
+
content = _content_blocks(parts)
|
|
379
|
+
|
|
380
|
+
# Trim to MAX_CARD_BLOCKS - reserve space for status/interaction
|
|
381
|
+
max_content = MAX_CARD_BLOCKS - 5
|
|
382
|
+
if len(content) > max_content:
|
|
383
|
+
content = content[:max_content]
|
|
384
|
+
|
|
385
|
+
body: list[dict] = []
|
|
386
|
+
body.extend(content)
|
|
387
|
+
|
|
388
|
+
if not content:
|
|
389
|
+
body.append(_md_block("_等待响应..._"))
|
|
390
|
+
|
|
391
|
+
body.append(_divider())
|
|
392
|
+
body.append(_status_block(status))
|
|
393
|
+
|
|
394
|
+
if permission:
|
|
395
|
+
body.append(_divider())
|
|
396
|
+
body.extend(_permission_buttons(permission))
|
|
397
|
+
elif question:
|
|
398
|
+
body.append(_divider())
|
|
399
|
+
body.extend(_question_buttons(question))
|
|
400
|
+
elif status == "busy" and session_id:
|
|
401
|
+
body.append(_abort_button(session_id))
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
"schema": "2.0",
|
|
405
|
+
"body": {"elements": body},
|
|
406
|
+
"header": {
|
|
407
|
+
"title": {"tag": "plain_text", "content": "dfcode"},
|
|
408
|
+
"template": "blue" if status == "busy" else "green",
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def build_frozen_card(parts: list[PartInfo]) -> dict:
|
|
414
|
+
"""Build a completed/frozen card — no interaction area."""
|
|
415
|
+
content = _content_blocks(parts)
|
|
416
|
+
body: list[dict] = list(content)
|
|
417
|
+
if not body:
|
|
418
|
+
body.append(_md_block("_(empty)_"))
|
|
419
|
+
body.append(_divider())
|
|
420
|
+
body.append(_note("✅ 完成"))
|
|
421
|
+
return {
|
|
422
|
+
"schema": "2.0",
|
|
423
|
+
"body": {"elements": body},
|
|
424
|
+
"header": {
|
|
425
|
+
"title": {"tag": "plain_text", "content": "dfcode"},
|
|
426
|
+
"template": "green",
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def build_menu_card(commands: Optional[list[dict]] = None) -> dict:
|
|
432
|
+
"""Build a command help/menu card."""
|
|
433
|
+
default_commands = [
|
|
434
|
+
("/new [title]", "创建新会话"),
|
|
435
|
+
("/attach <session_id>", "绑定到已有会话"),
|
|
436
|
+
("/detach", "解绑当前会话"),
|
|
437
|
+
("/list", "列出所有活跃会话"),
|
|
438
|
+
("/status", "查看当前会话状态"),
|
|
439
|
+
("/kill [session_id]", "终止会话"),
|
|
440
|
+
("/menu", "显示此帮助"),
|
|
441
|
+
]
|
|
442
|
+
cmds = commands or [{"cmd": c, "desc": d} for c, d in default_commands]
|
|
443
|
+
lines = ["**dfcode 命令列表**\n"]
|
|
444
|
+
for item in cmds:
|
|
445
|
+
cmd = item.get("cmd", "")
|
|
446
|
+
desc = item.get("desc", "")
|
|
447
|
+
lines.append(f"`{cmd}` — {desc}")
|
|
448
|
+
return {
|
|
449
|
+
"schema": "2.0",
|
|
450
|
+
"body": {"elements": [_md_block("\n".join(lines))]},
|
|
451
|
+
"header": {
|
|
452
|
+
"title": {"tag": "plain_text", "content": "命令菜单"},
|
|
453
|
+
"template": "grey",
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def build_command_response_card(title: str, content: str) -> dict:
|
|
459
|
+
"""Build a simple informational response card."""
|
|
460
|
+
return {
|
|
461
|
+
"schema": "2.0",
|
|
462
|
+
"body": {"elements": [_md_block(markdown_to_lark(content))]},
|
|
463
|
+
"header": {
|
|
464
|
+
"title": {"tag": "plain_text", "content": title},
|
|
465
|
+
"template": "grey",
|
|
466
|
+
},
|
|
467
|
+
}
|