feat(question): implement AskUserQuestion tool support
- Add question card builder and answer handling in feishu.py - Extend SDKSession with pending question state and answer method - Update card callback handler to support question answers - Add test cases for question flow and card responses - Document usage with test_can_use_tool_ask.py example
This commit is contained in:
@@ -190,6 +190,64 @@ def build_approval_card(conv_id: str, tool_name: str, summary: str, timeout: int
|
||||
}
|
||||
|
||||
|
||||
def build_question_card(conv_id: str, questions: list[dict]) -> dict:
|
||||
"""Build a question card for AskUserQuestion (schema 2.0).
|
||||
|
||||
Each question's options become buttons. The first question is shown
|
||||
prominently; multi-question support shows them sequentially.
|
||||
"""
|
||||
elements: list[dict] = []
|
||||
|
||||
for i, q in enumerate(questions):
|
||||
question_text = q.get("question", "")
|
||||
header = q.get("header", "")
|
||||
options = q.get("options", [])
|
||||
|
||||
if header:
|
||||
elements.append({
|
||||
"tag": "markdown",
|
||||
"content": f"**{header}**\n{question_text}",
|
||||
})
|
||||
else:
|
||||
elements.append({
|
||||
"tag": "markdown",
|
||||
"content": f"**{question_text}**",
|
||||
})
|
||||
|
||||
for opt in options:
|
||||
label = opt.get("label", "")
|
||||
desc = opt.get("description", "")
|
||||
button_text = f"{label} — {desc}" if desc else label
|
||||
# Truncate long button text
|
||||
if len(button_text) > 60:
|
||||
button_text = button_text[:57] + "..."
|
||||
elements.append({
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": button_text},
|
||||
"type": "default",
|
||||
"value": {
|
||||
"action": "answer_question",
|
||||
"conv_id": conv_id,
|
||||
"question": question_text,
|
||||
"answer": label,
|
||||
},
|
||||
})
|
||||
|
||||
elements.append({
|
||||
"tag": "div",
|
||||
"text": {"tag": "plain_text", "content": "也可直接输入文字回复"},
|
||||
})
|
||||
|
||||
return {
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": "❓ Claude Code 提问"},
|
||||
"template": "blue",
|
||||
},
|
||||
"body": {"elements": elements},
|
||||
}
|
||||
|
||||
|
||||
async def send_file(receive_id: str, receive_id_type: str, file_path: str, file_type: str = "stream") -> None:
|
||||
"""
|
||||
Upload a local file to Feishu and send it as a file message.
|
||||
|
||||
+100
-42
@@ -148,6 +148,27 @@ async def _process_message(user_id: str, chat_id: str, text: str) -> None:
|
||||
await send_text(chat_id, "chat_id", label)
|
||||
return
|
||||
|
||||
# Text answer fallback: any text reply when a question is pending
|
||||
from orchestrator.agent import agent as _agent
|
||||
from agent.manager import manager as _manager
|
||||
conv_id = _agent.get_active_conv(user_id)
|
||||
if conv_id:
|
||||
session = _manager._sessions.get(conv_id)
|
||||
if (
|
||||
session
|
||||
and session.sdk_session
|
||||
and session.sdk_session._pending_question
|
||||
and not session.sdk_session._pending_question.done()
|
||||
and session.sdk_session._pending_question_data
|
||||
):
|
||||
# Use text as answer to the first pending question
|
||||
questions = session.sdk_session._pending_question_data.get("questions", [])
|
||||
if questions:
|
||||
q_text = questions[0].get("question", "")
|
||||
await _manager.answer_question(conv_id, {q_text: text.strip()})
|
||||
await send_text(chat_id, "chat_id", f"✅ 已回答: {text.strip()}")
|
||||
return
|
||||
|
||||
from config import ROUTER_MODE
|
||||
if ROUTER_MODE:
|
||||
from router.nodes import get_node_registry
|
||||
@@ -215,77 +236,108 @@ def _handle_any(data: lark.CustomizedEvent) -> None:
|
||||
logger.info("RAW CustomizedEvent: %s", marshaled[:500])
|
||||
|
||||
|
||||
def _handle_card_action(data: lark.CustomizedEvent) -> dict | None:
|
||||
"""Handle Feishu card button clicks (approval approve/deny).
|
||||
def _handle_card_action(data: "P2CardActionTrigger") -> "P2CardActionTriggerResponse":
|
||||
"""Handle Feishu card button clicks via register_p2_card_action_trigger.
|
||||
|
||||
Per docs/feishu/card_callback_communication.md:
|
||||
- Must respond within 3 seconds
|
||||
- Return toast + updated card to give user visual feedback
|
||||
- Return P2CardActionTriggerResponse with toast + updated card
|
||||
"""
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
CallBackCard, CallBackToast, P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
def _response(toast_type: str, toast_text: str, card_data: dict) -> P2CardActionTriggerResponse:
|
||||
resp = P2CardActionTriggerResponse()
|
||||
toast = CallBackToast()
|
||||
toast.type = toast_type
|
||||
toast.content = toast_text
|
||||
resp.toast = toast
|
||||
card = CallBackCard()
|
||||
card.type = "raw"
|
||||
card.data = card_data
|
||||
resp.card = card
|
||||
return resp
|
||||
|
||||
def _empty_response() -> P2CardActionTriggerResponse:
|
||||
return P2CardActionTriggerResponse()
|
||||
|
||||
try:
|
||||
marshaled = lark.JSON.marshal(data)
|
||||
if not marshaled:
|
||||
return None
|
||||
event = data.event
|
||||
if not event:
|
||||
return _empty_response()
|
||||
|
||||
payload = json.loads(marshaled) if isinstance(marshaled, str) else marshaled
|
||||
event = payload.get("event", {})
|
||||
action = event.get("action", {})
|
||||
value = action.get("value", {})
|
||||
action = event.action
|
||||
if not action:
|
||||
return _empty_response()
|
||||
|
||||
action_type = value.get("action") # "approve" or "deny"
|
||||
value: dict = action.value or {}
|
||||
action_type = value.get("action")
|
||||
conv_id = value.get("conv_id")
|
||||
|
||||
if not action_type or not conv_id:
|
||||
logger.debug("Card action without action/conv_id: %s", value)
|
||||
return None
|
||||
return _empty_response()
|
||||
|
||||
approved = action_type == "approve"
|
||||
operator_open_id = event.get("operator", {}).get("open_id", "")
|
||||
operator_open_id = (event.operator.open_id or "") if event.operator else ""
|
||||
logger.info(
|
||||
"Card action: %s for session %s by %s",
|
||||
"Card action: %s for session %s by ...%s",
|
||||
action_type, conv_id, operator_open_id[-8:],
|
||||
)
|
||||
|
||||
# Dispatch approval to SDKSession (async, fire-and-forget)
|
||||
# --- AskUserQuestion answer ---
|
||||
if action_type == "answer_question":
|
||||
question = value.get("question", "")
|
||||
answer = value.get("answer", "")
|
||||
if _main_loop:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
_handle_question_answer_async(conv_id, question, answer), _main_loop
|
||||
)
|
||||
return _response(
|
||||
"success", f"已选择: {answer}",
|
||||
{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": "❓ Claude Code 提问"},
|
||||
"template": "green",
|
||||
},
|
||||
"body": {
|
||||
"elements": [
|
||||
{"tag": "markdown", "content": f"**{question}**\n\n✅ 已选择: **{answer}**"},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# --- Tool approval ---
|
||||
approved = action_type == "approve"
|
||||
if _main_loop:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
_handle_approval_async(conv_id, approved), _main_loop
|
||||
)
|
||||
|
||||
# Respond to callback within 3s: toast + updated card showing result
|
||||
if approved:
|
||||
toast_type, toast_text = "success", "✅ 已批准"
|
||||
card_status = "✅ **已批准**"
|
||||
template = "green"
|
||||
card_status, template = "✅ **已批准**", "green"
|
||||
else:
|
||||
toast_type, toast_text = "warning", "❌ 已拒绝"
|
||||
card_status = "❌ **已拒绝**"
|
||||
template = "red"
|
||||
card_status, template = "❌ **已拒绝**", "red"
|
||||
|
||||
return {
|
||||
"toast": {
|
||||
"type": toast_type,
|
||||
"content": toast_text,
|
||||
},
|
||||
"card": {
|
||||
"type": "raw",
|
||||
"data": {
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": "🔐 权限审批"},
|
||||
"template": template,
|
||||
},
|
||||
"body": {
|
||||
"elements": [
|
||||
{"tag": "markdown", "content": card_status},
|
||||
],
|
||||
},
|
||||
return _response(
|
||||
toast_type, toast_text,
|
||||
{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": "🔐 权限审批"},
|
||||
"template": template,
|
||||
},
|
||||
"body": {"elements": [{"tag": "markdown", "content": card_status}]},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error handling card action")
|
||||
return None
|
||||
return P2CardActionTriggerResponse()
|
||||
|
||||
|
||||
async def _handle_approval_async(conv_id: str, approved: bool) -> None:
|
||||
@@ -294,13 +346,19 @@ async def _handle_approval_async(conv_id: str, approved: bool) -> None:
|
||||
await manager.approve(conv_id, approved)
|
||||
|
||||
|
||||
async def _handle_question_answer_async(conv_id: str, question: str, answer: str) -> None:
|
||||
"""Process a question answer from card callback."""
|
||||
from agent.manager import manager
|
||||
await manager.answer_question(conv_id, {question: answer})
|
||||
|
||||
|
||||
def build_event_handler() -> lark.EventDispatcherHandler:
|
||||
"""Construct the EventDispatcherHandler with all registered callbacks."""
|
||||
handler = (
|
||||
lark.EventDispatcherHandler.builder("", "")
|
||||
.register_p2_im_message_receive_v1(_handle_message)
|
||||
.register_p1_customized_event("im.message.receive_v1", _handle_any)
|
||||
.register_p1_customized_event("card.action.trigger", _handle_card_action)
|
||||
.register_p2_card_action_trigger(_handle_card_action)
|
||||
.build()
|
||||
)
|
||||
return handler
|
||||
|
||||
Reference in New Issue
Block a user