270 lines
8.0 KiB
Python
270 lines
8.0 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:
|
|
"""
|
|
Send a plain-text message to a Feishu chat or user.
|
|
Automatically splits long messages into multiple parts with [1/N] headers.
|
|
|
|
Args:
|
|
receive_id: chat_id or open_id depending on receive_id_type.
|
|
receive_id_type: "chat_id" | "open_id" | "user_id" | "union_id".
|
|
text: message content.
|
|
"""
|
|
parts = _split_message(text)
|
|
loop = asyncio.get_running_loop()
|
|
|
|
for i, part in enumerate(parts):
|
|
logger.debug(
|
|
"[feishu] send_text to=%s type=%s part=%d/%d len=%d",
|
|
receive_id, receive_id_type, i + 1, len(parts), len(part),
|
|
)
|
|
content = json.dumps({"text": part}, ensure_ascii=False)
|
|
|
|
request = (
|
|
CreateMessageRequest.builder()
|
|
.receive_id_type(receive_id_type)
|
|
.request_body(
|
|
CreateMessageRequestBody.builder()
|
|
.receive_id(receive_id)
|
|
.msg_type("text")
|
|
.content(content)
|
|
.build()
|
|
)
|
|
.build()
|
|
)
|
|
|
|
response = await loop.run_in_executor(
|
|
None,
|
|
lambda: _client.im.v1.message.create(request),
|
|
)
|
|
|
|
if not response.success():
|
|
logger.error(
|
|
"Feishu send_text failed: code=%s msg=%s",
|
|
response.code,
|
|
response.msg,
|
|
)
|
|
return
|
|
else:
|
|
logger.debug("Sent message part %d/%d to %s (%s)", i + 1, len(parts), receive_id, receive_id_type)
|
|
|
|
if len(parts) > 1 and i < len(parts) - 1:
|
|
await asyncio.sleep(0.3)
|
|
|
|
|
|
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 " "
|
|
started = "🟢" if s["started"] else "🟡"
|
|
lines.append(f"{marker} {i}. {started} `{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}},
|
|
],
|
|
}
|
|
|
|
|
|
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)
|