"""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_event_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_card(receive_id: str, receive_id_type: str, title: str, content: str, buttons: list[dict] | None = None) -> None: """ Send an interactive card message. Args: receive_id: chat_id or open_id receive_id_type: "chat_id" | "open_id" | "user_id" | "union_id" title: Card title content: Card content (markdown supported) buttons: List of button dicts with "text" and "value" keys """ elements = [ { "tag": "div", "text": { "tag": "lark_md", "content": content, }, }, ] if buttons: actions = [] for btn in buttons: actions.append({ "tag": "button", "text": {"tag": "plain_text", "content": btn.get("text", "Button")}, "type": "primary", "value": btn.get("value", {}), }) elements.append({"tag": "action", "actions": actions}) card = { "type": "template", "data": { "template_id": "AAqkz9****", "template_variable": { "title": title, "elements": elements, }, }, } card_content = { "config": {"wide_screen_mode": True}, "header": { "title": {"tag": "plain_text", "content": title}, "template": "blue", }, "elements": elements, } loop = asyncio.get_event_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_content, 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) 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_event_loop() # Step 1: Upload file → get file_key with open(path, "rb") as f: file_data = f.read() def _upload(): req = ( CreateFileRequest.builder() .request_body( CreateFileRequestBody.builder() .file_type(file_type) .file_name(file_name) .file(file_data) .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) def build_session_card(conv_id: str, cwd: str, started: bool) -> dict: """Build a session status card.""" status = "🟢 Active" if started else "🟡 Ready" content = f"**Session ID:** `{conv_id}`\n**Directory:** `{cwd}`\n**Status:** {status}" return { "config": {"wide_screen_mode": True}, "header": { "title": {"tag": "plain_text", "content": "Claude Code Session"}, "template": "turquoise", }, "elements": [ {"tag": "div", "text": {"tag": "lark_md", "content": content}}, {"tag": "hr"}, { "tag": "action", "actions": [ {"tag": "button", "text": {"tag": "plain_text", "content": "Continue"}, "type": "primary", "value": {"action": "continue", "conv_id": conv_id}}, {"tag": "button", "text": {"tag": "plain_text", "content": "Close"}, "type": "default", "value": {"action": "close", "conv_id": conv_id}}, ], }, ], }