PhoneWork/bot/feishu.py
Yuyao Huang 72ebf3b75d 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
2026-04-02 08:52:50 +08:00

323 lines
9.8 KiB
Python

"""Feishu API client: send messages back to users."""
from __future__ import annotations
import asyncio
import json
import logging
import lark_oapi as lark
from lark_oapi.api.im.v1 import (
CreateFileRequest,
CreateFileRequestBody,
CreateMessageRequest,
CreateMessageRequestBody,
)
from config import FEISHU_APP_ID, FEISHU_APP_SECRET
logger = logging.getLogger(__name__)
MAX_TEXT_LEN = 3900
def _make_client() -> lark.Client:
return (
lark.Client.builder()
.app_id(FEISHU_APP_ID)
.app_secret(FEISHU_APP_SECRET)
.log_level(lark.LogLevel.WARNING)
.build()
)
_client = _make_client()
def _split_message(text: str) -> list[str]:
if len(text) <= MAX_TEXT_LEN:
return [text]
parts: list[str] = []
remaining = text
while remaining:
if len(remaining) <= MAX_TEXT_LEN:
parts.append(remaining)
break
chunk = remaining[:MAX_TEXT_LEN]
last_newline = chunk.rfind("\n")
if last_newline > MAX_TEXT_LEN // 2:
chunk = remaining[:last_newline + 1]
parts.append(chunk)
remaining = remaining[len(chunk):]
total = len(parts)
headered_parts = []
for i, part in enumerate(parts, 1):
headered_parts.append(f"[{i}/{total}]\n{part}")
return headered_parts
async def send_text(receive_id: str, receive_id_type: str, text: str) -> None:
"""Alias for send_markdown. All messages are sent as markdown cards."""
await send_markdown(receive_id, receive_id_type, text)
async def send_markdown(receive_id: str, receive_id_type: str, content: str) -> None:
"""
Send a markdown card message. Used for LLM/agent replies that contain
formatted text (code blocks, headings, bold, lists, etc.).
Automatically splits long content into multiple cards.
"""
parts = _split_message(content)
for i, part in enumerate(parts):
card = {
"schema": "2.0",
"body": {
"elements": [
{
"tag": "markdown",
"content": part,
}
]
}
}
await send_card(receive_id, receive_id_type, card)
if len(parts) > 1 and i < len(parts) - 1:
await asyncio.sleep(0.3)
async def send_card(receive_id: str, receive_id_type: str, card: dict) -> None:
"""
Send an interactive card message.
Args:
receive_id: chat_id or open_id depending on receive_id_type.
receive_id_type: "chat_id" | "open_id" | "user_id" | "union_id".
card: Card content dict (Feishu card JSON schema).
"""
loop = asyncio.get_running_loop()
request = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type("interactive")
.content(json.dumps(card, ensure_ascii=False))
.build()
)
.build()
)
response = await loop.run_in_executor(
None,
lambda: _client.im.v1.message.create(request),
)
if not response.success():
logger.error("Feishu send_card failed: code=%s msg=%s", response.code, response.msg)
else:
logger.debug("Sent card to %s (%s)", receive_id, receive_id_type)
def build_sessions_card(sessions: list[dict], active_conv_id: str | None, mode: str) -> dict:
"""Build a card showing all sessions with active marker and mode info."""
if sessions:
lines = []
for i, s in enumerate(sessions, 1):
marker = "" if s["conv_id"] == active_conv_id else " "
status = "🔵" if s.get("busy") else ""
lines.append(f"{marker} {i}. {status} `{s['conv_id']}` — `{s['cwd']}`")
sessions_md = "\n".join(lines)
else:
sessions_md = "_No active sessions_"
content = f"{sessions_md}\n\n**Mode:** {mode}"
return {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": "Claude Code Sessions"},
"template": "turquoise",
},
"elements": [
{"tag": "div", "text": {"tag": "lark_md", "content": content}},
],
}
def build_approval_card(conv_id: str, tool_name: str, summary: str, timeout: int = 120) -> dict:
"""Build an approval card for a tool call (schema 2.0, with approve/deny buttons).
Note: JSON 2.0 does NOT support "action" wrapper or "note" components.
Buttons go directly in elements; use div with small text for the note.
"""
return {
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "🔐 权限审批"},
"template": "orange",
},
"body": {
"elements": [
{
"tag": "markdown",
"content": f"**工具:** `{tool_name}`\n**参数:** {summary}",
},
{
"tag": "button",
"text": {"tag": "plain_text", "content": "✅ 批准"},
"type": "primary",
"value": {"action": "approve", "conv_id": conv_id},
},
{
"tag": "button",
"text": {"tag": "plain_text", "content": "❌ 拒绝"},
"type": "danger",
"value": {"action": "deny", "conv_id": conv_id},
},
{
"tag": "div",
"text": {
"tag": "plain_text",
"content": f"超时 {timeout}s 自动拒绝 | 也可回复 y/n",
},
},
],
},
}
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.
Args:
receive_id: chat_id or open_id depending on receive_id_type.
receive_id_type: "chat_id" | "open_id" | "user_id" | "union_id".
file_path: Absolute path to the local file to send.
file_type: Feishu file type — "stream" (generic), "opus", "mp4", "pdf", "doc", "xls", "ppt".
"""
import os
path = os.path.abspath(file_path)
file_name = os.path.basename(path)
loop = asyncio.get_running_loop()
# Step 1: Upload file → get file_key
def _upload():
with open(path, "rb") as f:
req = (
CreateFileRequest.builder()
.request_body(
CreateFileRequestBody.builder()
.file_type(file_type)
.file_name(file_name)
.file(f)
.build()
)
.build()
)
return _client.im.v1.file.create(req)
upload_resp = await loop.run_in_executor(None, _upload)
if not upload_resp.success():
logger.error(
"Feishu file upload failed: code=%s msg=%s",
upload_resp.code,
upload_resp.msg,
)
return
file_key = upload_resp.data.file_key
logger.debug("Uploaded file %r → file_key=%r", file_name, file_key)
# Step 2: Send file message using the file_key
content = json.dumps({"file_key": file_key}, ensure_ascii=False)
request = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type("file")
.content(content)
.build()
)
.build()
)
send_resp = await loop.run_in_executor(
None,
lambda: _client.im.v1.message.create(request),
)
if not send_resp.success():
logger.error(
"Feishu send_file failed: code=%s msg=%s",
send_resp.code,
send_resp.msg,
)
else:
logger.debug("Sent file %r to %s (%s)", file_name, receive_id, receive_id_type)