返回 JoyAI-Echo
feishu.py
1 """Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection."""
2
3 import asyncio
4 import importlib.util
5 import json
6 import os
7 import re
8 import threading
9 import time
10 import uuid
11 from collections import OrderedDict
12 from dataclasses import dataclass
13 from typing import Any, Literal
14
15 from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
16 from loguru import logger
17 from pydantic import Field
18
19 from nanobot.bus.events import OutboundMessage
20 from nanobot.bus.queue import MessageBus
21 from nanobot.channels.base import BaseChannel
22 from nanobot.config.paths import get_media_dir
23 from nanobot.config.schema import Base
24
25 from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
26
27 FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
28
29 # Message type display mapping
30 MSG_TYPE_MAP = {
31 "image": "[image]",
32 "audio": "[audio]",
33 "file": "[file]",
34 "sticker": "[sticker]",
35 }
36
37
38 def _extract_share_card_content(content_json: dict, msg_type: str) -> str:
39 """Extract text representation from share cards and interactive messages."""
40 parts = []
41
42 if msg_type == "share_chat":
43 parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
44 elif msg_type == "share_user":
45 parts.append(f"[shared user: {content_json.get('user_id', '')}]")
46 elif msg_type == "interactive":
47 parts.extend(_extract_interactive_content(content_json))
48 elif msg_type == "share_calendar_event":
49 parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]")
50 elif msg_type == "system":
51 parts.append("[system message]")
52 elif msg_type == "merge_forward":
53 parts.append("[merged forward messages]")
54
55 return "\n".join(parts) if parts else f"[{msg_type}]"
56
57
58 def _extract_interactive_content(content: dict) -> list[str]:
59 """Recursively extract text and links from interactive card content."""
60 parts = []
61
62 if isinstance(content, str):
63 try:
64 content = json.loads(content)
65 except (json.JSONDecodeError, TypeError):
66 return [content] if content.strip() else []
67
68 if not isinstance(content, dict):
69 return parts
70
71 if "title" in content:
72 title = content["title"]
73 if isinstance(title, dict):
74 title_content = title.get("content", "") or title.get("text", "")
75 if title_content:
76 parts.append(f"title: {title_content}")
77 elif isinstance(title, str):
78 parts.append(f"title: {title}")
79
80 for elements in (
81 content.get("elements", []) if isinstance(content.get("elements"), list) else []
82 ):
83 for element in elements:
84 parts.extend(_extract_element_content(element))
85
86 card = content.get("card", {})
87 if card:
88 parts.extend(_extract_interactive_content(card))
89
90 header = content.get("header", {})
91 if header:
92 header_title = header.get("title", {})
93 if isinstance(header_title, dict):
94 header_text = header_title.get("content", "") or header_title.get("text", "")
95 if header_text:
96 parts.append(f"title: {header_text}")
97
98 return parts
99
100
101 def _extract_element_content(element: dict) -> list[str]:
102 """Extract content from a single card element."""
103 parts = []
104
105 if not isinstance(element, dict):
106 return parts
107
108 tag = element.get("tag", "")
109
110 if tag in ("markdown", "lark_md"):
111 content = element.get("content", "")
112 if content:
113 parts.append(content)
114
115 elif tag == "div":
116 text = element.get("text", {})
117 if isinstance(text, dict):
118 text_content = text.get("content", "") or text.get("text", "")
119 if text_content:
120 parts.append(text_content)
121 elif isinstance(text, str):
122 parts.append(text)
123 for field in element.get("fields", []):
124 if isinstance(field, dict):
125 field_text = field.get("text", {})
126 if isinstance(field_text, dict):
127 c = field_text.get("content", "")
128 if c:
129 parts.append(c)
130
131 elif tag == "a":
132 href = element.get("href", "")
133 text = element.get("text", "")
134 if href:
135 parts.append(f"link: {href}")
136 if text:
137 parts.append(text)
138
139 elif tag == "button":
140 text = element.get("text", {})
141 if isinstance(text, dict):
142 c = text.get("content", "")
143 if c:
144 parts.append(c)
145 url = element.get("url", "") or element.get("multi_url", {}).get("url", "")
146 if url:
147 parts.append(f"link: {url}")
148
149 elif tag == "img":
150 alt = element.get("alt", {})
151 parts.append(alt.get("content", "[image]") if isinstance(alt, dict) else "[image]")
152
153 elif tag == "note":
154 for ne in element.get("elements", []):
155 parts.extend(_extract_element_content(ne))
156
157 elif tag == "column_set":
158 for col in element.get("columns", []):
159 for ce in col.get("elements", []):
160 parts.extend(_extract_element_content(ce))
161
162 elif tag == "plain_text":
163 content = element.get("content", "")
164 if content:
165 parts.append(content)
166
167 else:
168 for ne in element.get("elements", []):
169 parts.extend(_extract_element_content(ne))
170
171 return parts
172
173
174 def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
175 """Extract text and image keys from Feishu post (rich text) message.
176
177 Handles three payload shapes:
178 - Direct: {"title": "...", "content": [[...]]}
179 - Localized: {"zh_cn": {"title": "...", "content": [...]}}
180 - Wrapped: {"post": {"zh_cn": {"title": "...", "content": [...]}}}
181 """
182
183 def _parse_block(block: dict) -> tuple[str | None, list[str]]:
184 if not isinstance(block, dict) or not isinstance(block.get("content"), list):
185 return None, []
186 texts, images = [], []
187 if title := block.get("title"):
188 texts.append(title)
189 for row in block["content"]:
190 if not isinstance(row, list):
191 continue
192 for el in row:
193 if not isinstance(el, dict):
194 continue
195 tag = el.get("tag")
196 if tag in ("text", "a"):
197 texts.append(el.get("text", ""))
198 elif tag == "at":
199 texts.append(f"@{el.get('user_name', 'user')}")
200 elif tag == "code_block":
201 lang = el.get("language", "")
202 code_text = el.get("text", "")
203 texts.append(f"\n```{lang}\n{code_text}\n```\n")
204 elif tag == "img" and (key := el.get("image_key")):
205 images.append(key)
206 return (" ".join(texts).strip() or None), images
207
208 # Unwrap optional {"post": ...} envelope
209 root = content_json
210 if isinstance(root, dict) and isinstance(root.get("post"), dict):
211 root = root["post"]
212 if not isinstance(root, dict):
213 return "", []
214
215 # Direct format
216 if "content" in root:
217 text, imgs = _parse_block(root)
218 if text or imgs:
219 return text or "", imgs
220
221 # Localized: prefer known locales, then fall back to any dict child
222 for key in ("zh_cn", "en_us", "ja_jp"):
223 if key in root:
224 text, imgs = _parse_block(root[key])
225 if text or imgs:
226 return text or "", imgs
227 for val in root.values():
228 if isinstance(val, dict):
229 text, imgs = _parse_block(val)
230 if text or imgs:
231 return text or "", imgs
232
233 return "", []
234
235
236 def _extract_post_text(content_json: dict) -> str:
237 """Extract plain text from Feishu post (rich text) message content.
238
239 Legacy wrapper for _extract_post_content, returns only text.
240 """
241 text, _ = _extract_post_content(content_json)
242 return text
243
244
245 class FeishuConfig(Base):
246 """Feishu/Lark channel configuration using WebSocket long connection."""
247
248 enabled: bool = False
249 app_id: str = ""
250 app_secret: str = ""
251 encrypt_key: str = ""
252 verification_token: str = ""
253 allow_from: list[str] = Field(default_factory=list)
254 react_emoji: str = "THUMBSUP"
255 done_emoji: str | None = None # Emoji to show when task is completed (e.g., "DONE", "OK")
256 tool_hint_prefix: str = "\U0001f527" # Prefix for inline tool hints (default: 🔧)
257 group_policy: Literal["open", "mention"] = "mention"
258 reply_to_message: bool = False # If True, bot replies quote the user's original message
259 streaming: bool = True
260 domain: Literal["feishu", "lark"] = "feishu" # Set to "lark" for international Lark
261
262
263 _STREAM_ELEMENT_ID = "streaming_md"
264
265
266 @dataclass
267 class _FeishuStreamBuf:
268 """Per-chat streaming accumulator using CardKit streaming API."""
269
270 text: str = ""
271 card_id: str | None = None
272 sequence: int = 0
273 last_edit: float = 0.0
274
275
276 class FeishuChannel(BaseChannel):
277 """
278 Feishu/Lark channel using WebSocket long connection.
279
280 Uses WebSocket to receive events - no public IP or webhook required.
281
282 Requires:
283 - App ID and App Secret from Feishu Open Platform
284 - Bot capability enabled
285 - Event subscription enabled (im.message.receive_v1)
286 """
287
288 name = "feishu"
289 display_name = "Feishu"
290
291 _STREAM_EDIT_INTERVAL = 0.5 # throttle between CardKit streaming updates
292
293 @classmethod
294 def default_config(cls) -> dict[str, Any]:
295 return FeishuConfig().model_dump(by_alias=True)
296
297 def __init__(self, config: Any, bus: MessageBus):
298 import lark_oapi as lark
299
300 if isinstance(config, dict):
301 config = FeishuConfig.model_validate(config)
302 super().__init__(config, bus)
303 self.config: FeishuConfig = config
304 self._client: lark.Client = None
305 self._ws_client: Any = None
306 self._ws_thread: threading.Thread | None = None
307 self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache
308 self._loop: asyncio.AbstractEventLoop | None = None
309 self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
310 self._bot_open_id: str | None = None
311
312 @staticmethod
313 def _register_optional_event(builder: Any, method_name: str, handler: Any) -> Any:
314 """Register an event handler only when the SDK supports it."""
315 method = getattr(builder, method_name, None)
316 return method(handler) if callable(method) else builder
317
318 async def start(self) -> None:
319 """Start the Feishu bot with WebSocket long connection."""
320 if not FEISHU_AVAILABLE:
321 logger.error("Feishu SDK not installed. Run: pip install lark-oapi")
322 return
323
324 if not self.config.app_id or not self.config.app_secret:
325 logger.error("Feishu app_id and app_secret not configured")
326 return
327
328 import lark_oapi as lark
329
330 self._running = True
331 self._loop = asyncio.get_running_loop()
332
333 # Create Lark client for sending messages
334 domain = LARK_DOMAIN if self.config.domain == "lark" else FEISHU_DOMAIN
335 self._client = (
336 lark.Client.builder()
337 .app_id(self.config.app_id)
338 .app_secret(self.config.app_secret)
339 .domain(domain)
340 .log_level(lark.LogLevel.INFO)
341 .build()
342 )
343 builder = lark.EventDispatcherHandler.builder(
344 self.config.encrypt_key or "",
345 self.config.verification_token or "",
346 ).register_p2_im_message_receive_v1(self._on_message_sync)
347 builder = self._register_optional_event(
348 builder, "register_p2_im_message_reaction_created_v1", self._on_reaction_created
349 )
350 builder = self._register_optional_event(
351 builder, "register_p2_im_message_reaction_deleted_v1", self._on_reaction_deleted
352 )
353 builder = self._register_optional_event(
354 builder, "register_p2_im_message_message_read_v1", self._on_message_read
355 )
356 builder = self._register_optional_event(
357 builder,
358 "register_p2_im_chat_access_event_bot_p2p_chat_entered_v1",
359 self._on_bot_p2p_chat_entered,
360 )
361 event_handler = builder.build()
362
363 # Create WebSocket client for long connection
364 self._ws_client = lark.ws.Client(
365 self.config.app_id,
366 self.config.app_secret,
367 domain=domain,
368 event_handler=event_handler,
369 log_level=lark.LogLevel.INFO,
370 )
371
372 # Start WebSocket client in a separate thread with reconnect loop.
373 # A dedicated event loop is created for this thread so that lark_oapi's
374 # module-level `loop = asyncio.get_event_loop()` picks up an idle loop
375 # instead of the already-running main asyncio loop, which would cause
376 # "This event loop is already running" errors.
377 def run_ws():
378 import time
379
380 import lark_oapi.ws.client as _lark_ws_client
381
382 ws_loop = asyncio.new_event_loop()
383 asyncio.set_event_loop(ws_loop)
384 # Patch the module-level loop used by lark's ws Client.start()
385 _lark_ws_client.loop = ws_loop
386 try:
387 while self._running:
388 try:
389 self._ws_client.start()
390 except Exception as e:
391 logger.warning("Feishu WebSocket error: {}", e)
392 if self._running:
393 time.sleep(5)
394 finally:
395 ws_loop.close()
396
397 self._ws_thread = threading.Thread(target=run_ws, daemon=True)
398 self._ws_thread.start()
399
400 # Fetch bot's own open_id for accurate @mention matching
401 self._bot_open_id = await asyncio.get_running_loop().run_in_executor(
402 None, self._fetch_bot_open_id
403 )
404 if self._bot_open_id:
405 logger.info("Feishu bot open_id: {}", self._bot_open_id)
406 else:
407 logger.warning("Could not fetch bot open_id; @mention matching may be inaccurate")
408
409 logger.info("Feishu bot started with WebSocket long connection")
410 logger.info("No public IP required - using WebSocket to receive events")
411
412 # Keep running until stopped
413 while self._running:
414 await asyncio.sleep(1)
415
416 async def stop(self) -> None:
417 """
418 Stop the Feishu bot.
419
420 Notice: lark.ws.Client does not expose stop method, simply exiting the program will close the client.
421
422 Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86
423 """
424 self._running = False
425 logger.info("Feishu bot stopped")
426
427 def _fetch_bot_open_id(self) -> str | None:
428 """Fetch the bot's own open_id via GET /open-apis/bot/v3/info."""
429 try:
430 import lark_oapi as lark
431
432 request = (
433 lark.BaseRequest.builder()
434 .http_method(lark.HttpMethod.GET)
435 .uri("/open-apis/bot/v3/info")
436 .token_types({lark.AccessTokenType.APP})
437 .build()
438 )
439 response = self._client.request(request)
440 if response.success():
441 import json
442
443 data = json.loads(response.raw.content)
444 bot = (data.get("data") or data).get("bot") or data.get("bot") or {}
445 return bot.get("open_id")
446 logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg)
447 return None
448 except Exception as e:
449 logger.warning("Error fetching bot info: {}", e)
450 return None
451
452 @staticmethod
453 def _resolve_mentions(text: str, mentions: list[MentionEvent] | None) -> str:
454 """Replace @_user_n placeholders with actual user info from mentions.
455
456 Args:
457 text: The message text containing @_user_n placeholders
458 mentions: List of mention objects from Feishu message
459
460 Returns:
461 Text with placeholders replaced by @姓名 (open_id)
462 """
463 if not mentions or not text:
464 return text
465
466 for mention in mentions:
467 key = mention.key or None
468 if not key or key not in text:
469 continue
470
471 user_id_obj = mention.id or None
472 if not user_id_obj:
473 continue
474
475 open_id = user_id_obj.open_id
476 user_id = user_id_obj.user_id
477 name = mention.name or key
478
479 # Format: @姓名 (open_id, user_id: xxx)
480 if open_id and user_id:
481 replacement = f"@{name} ({open_id}, user id: {user_id})"
482 elif open_id:
483 replacement = f"@{name} ({open_id})"
484 else:
485 replacement = f"@{name}"
486
487 text = text.replace(key, replacement)
488
489 return text
490
491 def _is_bot_mentioned(self, message: Any) -> bool:
492 """Check if the bot is @mentioned in the message."""
493 raw_content = message.content or ""
494 if "@_all" in raw_content:
495 return True
496
497 for mention in getattr(message, "mentions", None) or []:
498 mid = getattr(mention, "id", None)
499 if not mid:
500 continue
501 mention_open_id = getattr(mid, "open_id", None) or ""
502 if self._bot_open_id:
503 if mention_open_id == self._bot_open_id:
504 return True
505 else:
506 # Fallback heuristic when bot open_id is unavailable
507 if not getattr(mid, "user_id", None) and mention_open_id.startswith("ou_"):
508 return True
509 return False
510
511 def _is_group_message_for_bot(self, message: Any) -> bool:
512 """Allow group messages when policy is open or bot is @mentioned."""
513 if self.config.group_policy == "open":
514 return True
515 return self._is_bot_mentioned(message)
516
517 def _add_reaction_sync(self, message_id: str, emoji_type: str) -> str | None:
518 """Sync helper for adding reaction (runs in thread pool)."""
519 from lark_oapi.api.im.v1 import (
520 CreateMessageReactionRequest,
521 CreateMessageReactionRequestBody,
522 Emoji,
523 )
524
525 try:
526 request = (
527 CreateMessageReactionRequest.builder()
528 .message_id(message_id)
529 .request_body(
530 CreateMessageReactionRequestBody.builder()
531 .reaction_type(Emoji.builder().emoji_type(emoji_type).build())
532 .build()
533 )
534 .build()
535 )
536
537 response = self._client.im.v1.message_reaction.create(request)
538
539 if not response.success():
540 logger.warning(
541 "Failed to add reaction: code={}, msg={}", response.code, response.msg
542 )
543 return None
544 else:
545 logger.debug("Added {} reaction to message {}", emoji_type, message_id)
546 return response.data.reaction_id if response.data else None
547 except Exception as e:
548 logger.warning("Error adding reaction: {}", e)
549 return None
550
551 async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None:
552 """
553 Add a reaction emoji to a message (non-blocking).
554
555 Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART
556 """
557 if not self._client:
558 return None
559
560 loop = asyncio.get_running_loop()
561 return await loop.run_in_executor(None, self._add_reaction_sync, message_id, emoji_type)
562
563 def _remove_reaction_sync(self, message_id: str, reaction_id: str) -> None:
564 """Sync helper for removing reaction (runs in thread pool)."""
565 from lark_oapi.api.im.v1 import DeleteMessageReactionRequest
566
567 try:
568 request = (
569 DeleteMessageReactionRequest.builder()
570 .message_id(message_id)
571 .reaction_id(reaction_id)
572 .build()
573 )
574
575 response = self._client.im.v1.message_reaction.delete(request)
576 if response.success():
577 logger.debug("Removed reaction {} from message {}", reaction_id, message_id)
578 else:
579 logger.debug(
580 "Failed to remove reaction: code={}, msg={}", response.code, response.msg
581 )
582 except Exception as e:
583 logger.debug("Error removing reaction: {}", e)
584
585 async def _remove_reaction(self, message_id: str, reaction_id: str) -> None:
586 """
587 Remove a reaction emoji from a message (non-blocking).
588
589 Used to clear the "processing" indicator after bot replies.
590 """
591 if not self._client or not reaction_id:
592 return
593
594 loop = asyncio.get_running_loop()
595 await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id)
596
597 # Regex to match markdown tables (header + separator + data rows)
598 _TABLE_RE = re.compile(
599 r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)",
600 re.MULTILINE,
601 )
602
603 _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
604
605 _CODE_BLOCK_RE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE)
606
607 # Markdown formatting patterns that should be stripped from plain-text
608 # surfaces like table cells and heading text.
609 _MD_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
610 _MD_BOLD_UNDERSCORE_RE = re.compile(r"__(.+?)__")
611 _MD_ITALIC_RE = re.compile(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)")
612 _MD_STRIKE_RE = re.compile(r"~~(.+?)~~")
613
614 @classmethod
615 def _strip_md_formatting(cls, text: str) -> str:
616 """Strip markdown formatting markers from text for plain display.
617
618 Feishu table cells do not support markdown rendering, so we remove
619 the formatting markers to keep the text readable.
620 """
621 # Remove bold markers
622 text = cls._MD_BOLD_RE.sub(r"\1", text)
623 text = cls._MD_BOLD_UNDERSCORE_RE.sub(r"\1", text)
624 # Remove italic markers
625 text = cls._MD_ITALIC_RE.sub(r"\1", text)
626 # Remove strikethrough markers
627 text = cls._MD_STRIKE_RE.sub(r"\1", text)
628 return text
629
630 @classmethod
631 def _parse_md_table(cls, table_text: str) -> dict | None:
632 """Parse a markdown table into a Feishu table element."""
633 lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()]
634 if len(lines) < 3:
635 return None
636
637 def split(_line: str) -> list[str]:
638 return [c.strip() for c in _line.strip("|").split("|")]
639
640 headers = [cls._strip_md_formatting(h) for h in split(lines[0])]
641 rows = [[cls._strip_md_formatting(c) for c in split(_line)] for _line in lines[2:]]
642 columns = [
643 {"tag": "column", "name": f"c{i}", "display_name": h, "width": "auto"}
644 for i, h in enumerate(headers)
645 ]
646 return {
647 "tag": "table",
648 "page_size": len(rows) + 1,
649 "columns": columns,
650 "rows": [
651 {f"c{i}": r[i] if i < len(r) else "" for i in range(len(headers))} for r in rows
652 ],
653 }
654
655 def _build_card_elements(self, content: str) -> list[dict]:
656 """Split content into div/markdown + table elements for Feishu card."""
657 elements, last_end = [], 0
658 for m in self._TABLE_RE.finditer(content):
659 before = content[last_end : m.start()]
660 if before.strip():
661 elements.extend(self._split_headings(before))
662 elements.append(
663 self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)}
664 )
665 last_end = m.end()
666 remaining = content[last_end:]
667 if remaining.strip():
668 elements.extend(self._split_headings(remaining))
669 return elements or [{"tag": "markdown", "content": content}]
670
671 @staticmethod
672 def _split_elements_by_table_limit(
673 elements: list[dict], max_tables: int = 1
674 ) -> list[list[dict]]:
675 """Split card elements into groups with at most *max_tables* table elements each.
676
677 Feishu cards have a hard limit of one table per card (API error 11310).
678 When the rendered content contains multiple markdown tables each table is
679 placed in a separate card message so every table reaches the user.
680 """
681 if not elements:
682 return [[]]
683 groups: list[list[dict]] = []
684 current: list[dict] = []
685 table_count = 0
686 for el in elements:
687 if el.get("tag") == "table":
688 if table_count >= max_tables:
689 if current:
690 groups.append(current)
691 current = []
692 table_count = 0
693 current.append(el)
694 table_count += 1
695 else:
696 current.append(el)
697 if current:
698 groups.append(current)
699 return groups or [[]]
700
701 def _split_headings(self, content: str) -> list[dict]:
702 """Split content by headings, converting headings to div elements."""
703 protected = content
704 code_blocks = []
705 for m in self._CODE_BLOCK_RE.finditer(content):
706 code_blocks.append(m.group(1))
707 protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1)
708
709 elements = []
710 last_end = 0
711 for m in self._HEADING_RE.finditer(protected):
712 before = protected[last_end : m.start()].strip()
713 if before:
714 elements.append({"tag": "markdown", "content": before})
715 text = self._strip_md_formatting(m.group(2).strip())
716 display_text = f"**{text}**" if text else ""
717 elements.append(
718 {
719 "tag": "div",
720 "text": {
721 "tag": "lark_md",
722 "content": display_text,
723 },
724 }
725 )
726 last_end = m.end()
727 remaining = protected[last_end:].strip()
728 if remaining:
729 elements.append({"tag": "markdown", "content": remaining})
730
731 for i, cb in enumerate(code_blocks):
732 for el in elements:
733 if el.get("tag") == "markdown":
734 el["content"] = el["content"].replace(f"\x00CODE{i}\x00", cb)
735
736 return elements or [{"tag": "markdown", "content": content}]
737
738 # ── Smart format detection ──────────────────────────────────────────
739 # Patterns that indicate "complex" markdown needing card rendering
740 _COMPLEX_MD_RE = re.compile(
741 r"```" # fenced code block
742 r"|^\|.+\|.*\n\s*\|[-:\s|]+\|" # markdown table (header + separator)
743 r"|^#{1,6}\s+", # headings
744 re.MULTILINE,
745 )
746
747 # Simple markdown patterns (bold, italic, strikethrough)
748 _SIMPLE_MD_RE = re.compile(
749 r"\*\*.+?\*\*" # **bold**
750 r"|__.+?__" # __bold__
751 r"|(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)" # *italic* (single *)
752 r"|~~.+?~~", # ~~strikethrough~~
753 re.DOTALL,
754 )
755
756 # Markdown link: [text](url)
757 _MD_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^\)]+)\)")
758
759 # Unordered list items
760 _LIST_RE = re.compile(r"^[\s]*[-*+]\s+", re.MULTILINE)
761
762 # Ordered list items
763 _OLIST_RE = re.compile(r"^[\s]*\d+\.\s+", re.MULTILINE)
764
765 # Max length for plain text format
766 _TEXT_MAX_LEN = 200
767
768 # Max length for post (rich text) format; beyond this, use card
769 _POST_MAX_LEN = 2000
770
771 @classmethod
772 def _detect_msg_format(cls, content: str) -> str:
773 """Determine the optimal Feishu message format for *content*.
774
775 Returns one of:
776 - ``"text"`` – plain text, short and no markdown
777 - ``"post"`` – rich text (links only, moderate length)
778 - ``"interactive"`` – card with full markdown rendering
779 """
780 stripped = content.strip()
781
782 # Complex markdown (code blocks, tables, headings) → always card
783 if cls._COMPLEX_MD_RE.search(stripped):
784 return "interactive"
785
786 # Long content → card (better readability with card layout)
787 if len(stripped) > cls._POST_MAX_LEN:
788 return "interactive"
789
790 # Has bold/italic/strikethrough → card (post format can't render these)
791 if cls._SIMPLE_MD_RE.search(stripped):
792 return "interactive"
793
794 # Has list items → card (post format can't render list bullets well)
795 if cls._LIST_RE.search(stripped) or cls._OLIST_RE.search(stripped):
796 return "interactive"
797
798 # Has links → post format (supports <a> tags)
799 if cls._MD_LINK_RE.search(stripped):
800 return "post"
801
802 # Short plain text → text format
803 if len(stripped) <= cls._TEXT_MAX_LEN:
804 return "text"
805
806 # Medium plain text without any formatting → post format
807 return "post"
808
809 @classmethod
810 def _markdown_to_post(cls, content: str) -> str:
811 """Convert markdown content to Feishu post message JSON.
812
813 Handles links ``[text](url)`` as ``a`` tags; everything else as ``text`` tags.
814 Each line becomes a paragraph (row) in the post body.
815 """
816 lines = content.strip().split("\n")
817 paragraphs: list[list[dict]] = []
818
819 for line in lines:
820 elements: list[dict] = []
821 last_end = 0
822
823 for m in cls._MD_LINK_RE.finditer(line):
824 # Text before this link
825 before = line[last_end : m.start()]
826 if before:
827 elements.append({"tag": "text", "text": before})
828 elements.append(
829 {
830 "tag": "a",
831 "text": m.group(1),
832 "href": m.group(2),
833 }
834 )
835 last_end = m.end()
836
837 # Remaining text after last link
838 remaining = line[last_end:]
839 if remaining:
840 elements.append({"tag": "text", "text": remaining})
841
842 # Empty line → empty paragraph for spacing
843 if not elements:
844 elements.append({"tag": "text", "text": ""})
845
846 paragraphs.append(elements)
847
848 post_body = {
849 "zh_cn": {
850 "content": paragraphs,
851 }
852 }
853 return json.dumps(post_body, ensure_ascii=False)
854
855 _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
856 _AUDIO_EXTS = {".opus"}
857 _VIDEO_EXTS = {".mp4", ".mov", ".avi"}
858 _FILE_TYPE_MAP = {
859 ".opus": "opus",
860 ".mp4": "mp4",
861 ".pdf": "pdf",
862 ".doc": "doc",
863 ".docx": "doc",
864 ".xls": "xls",
865 ".xlsx": "xls",
866 ".ppt": "ppt",
867 ".pptx": "ppt",
868 }
869
870 def _upload_image_sync(self, file_path: str) -> str | None:
871 """Upload an image to Feishu and return the image_key."""
872 from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody
873
874 try:
875 with open(file_path, "rb") as f:
876 request = (
877 CreateImageRequest.builder()
878 .request_body(
879 CreateImageRequestBody.builder().image_type("message").image(f).build()
880 )
881 .build()
882 )
883 response = self._client.im.v1.image.create(request)
884 if response.success():
885 image_key = response.data.image_key
886 logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key)
887 return image_key
888 else:
889 logger.error(
890 "Failed to upload image: code={}, msg={}", response.code, response.msg
891 )
892 return None
893 except Exception as e:
894 logger.error("Error uploading image {}: {}", file_path, e)
895 return None
896
897 def _upload_file_sync(self, file_path: str) -> str | None:
898 """Upload a file to Feishu and return the file_key."""
899 from lark_oapi.api.im.v1 import CreateFileRequest, CreateFileRequestBody
900
901 ext = os.path.splitext(file_path)[1].lower()
902 file_type = self._FILE_TYPE_MAP.get(ext, "stream")
903 file_name = os.path.basename(file_path)
904 try:
905 with open(file_path, "rb") as f:
906 request = (
907 CreateFileRequest.builder()
908 .request_body(
909 CreateFileRequestBody.builder()
910 .file_type(file_type)
911 .file_name(file_name)
912 .file(f)
913 .build()
914 )
915 .build()
916 )
917 response = self._client.im.v1.file.create(request)
918 if response.success():
919 file_key = response.data.file_key
920 logger.debug("Uploaded file {}: {}", file_name, file_key)
921 return file_key
922 else:
923 logger.error(
924 "Failed to upload file: code={}, msg={}", response.code, response.msg
925 )
926 return None
927 except Exception as e:
928 logger.error("Error uploading file {}: {}", file_path, e)
929 return None
930
931 def _download_image_sync(
932 self, message_id: str, image_key: str
933 ) -> tuple[bytes | None, str | None]:
934 """Download an image from Feishu message by message_id and image_key."""
935 from lark_oapi.api.im.v1 import GetMessageResourceRequest
936
937 try:
938 request = (
939 GetMessageResourceRequest.builder()
940 .message_id(message_id)
941 .file_key(image_key)
942 .type("image")
943 .build()
944 )
945 response = self._client.im.v1.message_resource.get(request)
946 if response.success():
947 file_data = response.file
948 # GetMessageResourceRequest returns BytesIO, need to read bytes
949 if hasattr(file_data, "read"):
950 file_data = file_data.read()
951 return file_data, response.file_name
952 else:
953 logger.error(
954 "Failed to download image: code={}, msg={}", response.code, response.msg
955 )
956 return None, None
957 except Exception as e:
958 logger.error("Error downloading image {}: {}", image_key, e)
959 return None, None
960
961 def _download_file_sync(
962 self, message_id: str, file_key: str, resource_type: str = "file"
963 ) -> tuple[bytes | None, str | None]:
964 """Download a file/audio/media from a Feishu message by message_id and file_key."""
965 from lark_oapi.api.im.v1 import GetMessageResourceRequest
966
967 # Feishu resource download API only accepts 'image' or 'file' as type.
968 # Both 'audio' and 'media' (video) messages use type='file' for download.
969 if resource_type in ("audio", "media"):
970 resource_type = "file"
971
972 try:
973 request = (
974 GetMessageResourceRequest.builder()
975 .message_id(message_id)
976 .file_key(file_key)
977 .type(resource_type)
978 .build()
979 )
980 response = self._client.im.v1.message_resource.get(request)
981 if response.success():
982 file_data = response.file
983 if hasattr(file_data, "read"):
984 file_data = file_data.read()
985 return file_data, response.file_name
986 else:
987 logger.error(
988 "Failed to download {}: code={}, msg={}",
989 resource_type,
990 response.code,
991 response.msg,
992 )
993 return None, None
994 except Exception:
995 logger.exception("Error downloading {} {}", resource_type, file_key)
996 return None, None
997
998 async def _download_and_save_media(
999 self, msg_type: str, content_json: dict, message_id: str | None = None
1000 ) -> tuple[str | None, str]:
1001 """
1002 Download media from Feishu and save to local disk.
1003
1004 Returns:
1005 (file_path, content_text) - file_path is None if download failed
1006 """
1007 loop = asyncio.get_running_loop()
1008 media_dir = get_media_dir("feishu")
1009
1010 data, filename = None, None
1011
1012 if msg_type == "image":
1013 image_key = content_json.get("image_key")
1014 if image_key and message_id:
1015 data, filename = await loop.run_in_executor(
1016 None, self._download_image_sync, message_id, image_key
1017 )
1018 if not filename:
1019 filename = f"{image_key[:16]}.jpg"
1020
1021 elif msg_type in ("audio", "file", "media"):
1022 file_key = content_json.get("file_key")
1023 if not file_key:
1024 logger.warning("Feishu {} message missing file_key: {}", msg_type, content_json)
1025 return None, f"[{msg_type}: missing file_key]"
1026 if not message_id:
1027 logger.warning("Feishu {} message missing message_id", msg_type)
1028 return None, f"[{msg_type}: missing message_id]"
1029
1030 data, filename = await loop.run_in_executor(
1031 None, self._download_file_sync, message_id, file_key, msg_type
1032 )
1033
1034 if not data:
1035 logger.warning("Feishu {} download failed: file_key={}", msg_type, file_key)
1036 return None, f"[{msg_type}: download failed]"
1037
1038 if not filename:
1039 filename = file_key[:16]
1040
1041 # Feishu voice messages are opus in OGG container.
1042 # Use .ogg extension for better Whisper compatibility.
1043 if msg_type == "audio":
1044 if not any(filename.endswith(ext) for ext in (".opus", ".ogg", ".oga")):
1045 filename = f"{filename}.ogg"
1046
1047 if data and filename:
1048 file_path = media_dir / filename
1049 file_path.write_bytes(data)
1050 logger.debug("Downloaded {} to {}", msg_type, file_path)
1051 return str(file_path), f"[{msg_type}: {filename}]"
1052
1053 return None, f"[{msg_type}: download failed]"
1054
1055 _REPLY_CONTEXT_MAX_LEN = 200
1056
1057 def _get_message_content_sync(self, message_id: str) -> str | None:
1058 """Fetch the text content of a Feishu message by ID (synchronous).
1059
1060 Returns a "[Reply to: ...]" context string, or None on failure.
1061 """
1062 from lark_oapi.api.im.v1 import GetMessageRequest
1063
1064 try:
1065 request = GetMessageRequest.builder().message_id(message_id).build()
1066 response = self._client.im.v1.message.get(request)
1067 if not response.success():
1068 logger.debug(
1069 "Feishu: could not fetch parent message {}: code={}, msg={}",
1070 message_id,
1071 response.code,
1072 response.msg,
1073 )
1074 return None
1075 items = getattr(response.data, "items", None)
1076 if not items:
1077 return None
1078 msg_obj = items[0]
1079 raw_content = getattr(msg_obj, "body", None)
1080 raw_content = getattr(raw_content, "content", None) if raw_content else None
1081 if not raw_content:
1082 return None
1083 try:
1084 content_json = json.loads(raw_content)
1085 except (json.JSONDecodeError, TypeError):
1086 return None
1087 msg_type = getattr(msg_obj, "msg_type", "")
1088 if msg_type == "text":
1089 text = content_json.get("text", "").strip()
1090 elif msg_type == "post":
1091 text, _ = _extract_post_content(content_json)
1092 text = text.strip()
1093 else:
1094 text = ""
1095 if not text:
1096 return None
1097 if len(text) > self._REPLY_CONTEXT_MAX_LEN:
1098 text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..."
1099 return f"[Reply to: {text}]"
1100 except Exception as e:
1101 logger.debug("Feishu: error fetching parent message {}: {}", message_id, e)
1102 return None
1103
1104 def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str) -> bool:
1105 """Reply to an existing Feishu message using the Reply API (synchronous)."""
1106 from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody
1107
1108 try:
1109 request = (
1110 ReplyMessageRequest.builder()
1111 .message_id(parent_message_id)
1112 .request_body(
1113 ReplyMessageRequestBody.builder().msg_type(msg_type).content(content).build()
1114 )
1115 .build()
1116 )
1117 response = self._client.im.v1.message.reply(request)
1118 if not response.success():
1119 logger.error(
1120 "Failed to reply to Feishu message {}: code={}, msg={}, log_id={}",
1121 parent_message_id,
1122 response.code,
1123 response.msg,
1124 response.get_log_id(),
1125 )
1126 return False
1127 logger.debug("Feishu reply sent to message {}", parent_message_id)
1128 return True
1129 except Exception as e:
1130 logger.error("Error replying to Feishu message {}: {}", parent_message_id, e)
1131 return False
1132
1133 def _send_message_sync(
1134 self, receive_id_type: str, receive_id: str, msg_type: str, content: str
1135 ) -> str | None:
1136 """Send a single message and return the message_id on success."""
1137 from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
1138
1139 try:
1140 request = (
1141 CreateMessageRequest.builder()
1142 .receive_id_type(receive_id_type)
1143 .request_body(
1144 CreateMessageRequestBody.builder()
1145 .receive_id(receive_id)
1146 .msg_type(msg_type)
1147 .content(content)
1148 .build()
1149 )
1150 .build()
1151 )
1152 response = self._client.im.v1.message.create(request)
1153 if not response.success():
1154 logger.error(
1155 "Failed to send Feishu {} message: code={}, msg={}, log_id={}",
1156 msg_type,
1157 response.code,
1158 response.msg,
1159 response.get_log_id(),
1160 )
1161 return None
1162 msg_id = getattr(response.data, "message_id", None)
1163 logger.debug("Feishu {} message sent to {}: {}", msg_type, receive_id, msg_id)
1164 return msg_id
1165 except Exception as e:
1166 logger.error("Error sending Feishu {} message: {}", msg_type, e)
1167 return None
1168
1169 def _create_streaming_card_sync(self, receive_id_type: str, chat_id: str) -> str | None:
1170 """Create a CardKit streaming card, send it to chat, return card_id."""
1171 from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody
1172
1173 card_json = {
1174 "schema": "2.0",
1175 "config": {"wide_screen_mode": True, "update_multi": True, "streaming_mode": True},
1176 "body": {
1177 "elements": [{"tag": "markdown", "content": "", "element_id": _STREAM_ELEMENT_ID}]
1178 },
1179 }
1180 try:
1181 request = (
1182 CreateCardRequest.builder()
1183 .request_body(
1184 CreateCardRequestBody.builder()
1185 .type("card_json")
1186 .data(json.dumps(card_json, ensure_ascii=False))
1187 .build()
1188 )
1189 .build()
1190 )
1191 response = self._client.cardkit.v1.card.create(request)
1192 if not response.success():
1193 logger.warning(
1194 "Failed to create streaming card: code={}, msg={}", response.code, response.msg
1195 )
1196 return None
1197 card_id = getattr(response.data, "card_id", None)
1198 if card_id:
1199 message_id = self._send_message_sync(
1200 receive_id_type,
1201 chat_id,
1202 "interactive",
1203 json.dumps({"type": "card", "data": {"card_id": card_id}}),
1204 )
1205 if message_id:
1206 return card_id
1207 logger.warning(
1208 "Created streaming card {} but failed to send it to {}", card_id, chat_id
1209 )
1210 return None
1211 except Exception as e:
1212 logger.warning("Error creating streaming card: {}", e)
1213 return None
1214
1215 def _stream_update_text_sync(self, card_id: str, content: str, sequence: int) -> bool:
1216 """Stream-update the markdown element on a CardKit card (typewriter effect)."""
1217 from lark_oapi.api.cardkit.v1 import (
1218 ContentCardElementRequest,
1219 ContentCardElementRequestBody,
1220 )
1221
1222 try:
1223 request = (
1224 ContentCardElementRequest.builder()
1225 .card_id(card_id)
1226 .element_id(_STREAM_ELEMENT_ID)
1227 .request_body(
1228 ContentCardElementRequestBody.builder()
1229 .content(content)
1230 .sequence(sequence)
1231 .build()
1232 )
1233 .build()
1234 )
1235 response = self._client.cardkit.v1.card_element.content(request)
1236 if not response.success():
1237 logger.warning(
1238 "Failed to stream-update card {}: code={}, msg={}",
1239 card_id,
1240 response.code,
1241 response.msg,
1242 )
1243 return False
1244 return True
1245 except Exception as e:
1246 logger.warning("Error stream-updating card {}: {}", card_id, e)
1247 return False
1248
1249 def _close_streaming_mode_sync(self, card_id: str, sequence: int) -> bool:
1250 """Turn off CardKit streaming_mode so the chat list preview exits the streaming placeholder.
1251
1252 Per Feishu docs, streaming cards keep a generating-style summary in the session list until
1253 streaming_mode is set to false via card settings (after final content update).
1254 Sequence must strictly exceed the previous card OpenAPI operation on this entity.
1255 """
1256 from lark_oapi.api.cardkit.v1 import SettingsCardRequest, SettingsCardRequestBody
1257
1258 settings_payload = json.dumps({"config": {"streaming_mode": False}}, ensure_ascii=False)
1259 try:
1260 request = (
1261 SettingsCardRequest.builder()
1262 .card_id(card_id)
1263 .request_body(
1264 SettingsCardRequestBody.builder()
1265 .settings(settings_payload)
1266 .sequence(sequence)
1267 .uuid(str(uuid.uuid4()))
1268 .build()
1269 )
1270 .build()
1271 )
1272 response = self._client.cardkit.v1.card.settings(request)
1273 if not response.success():
1274 logger.warning(
1275 "Failed to close streaming on card {}: code={}, msg={}",
1276 card_id,
1277 response.code,
1278 response.msg,
1279 )
1280 return False
1281 return True
1282 except Exception as e:
1283 logger.warning("Error closing streaming on card {}: {}", card_id, e)
1284 return False
1285
1286 async def send_delta(
1287 self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
1288 ) -> None:
1289 """Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.
1290
1291 Supported metadata keys:
1292 _stream_end: Finalize the streaming card.
1293 _tool_hint: Delta is a formatted tool hint (for display only).
1294 message_id: Original message id (used with _stream_end for reaction cleanup).
1295 reaction_id: Reaction id to remove on stream end.
1296 """
1297 if not self._client:
1298 return
1299 meta = metadata or {}
1300 loop = asyncio.get_running_loop()
1301 rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
1302
1303 # --- stream end: final update or fallback ---
1304 if meta.get("_stream_end"):
1305 if (message_id := meta.get("message_id")) and (reaction_id := meta.get("reaction_id")):
1306 await self._remove_reaction(message_id, reaction_id)
1307 # Add completion emoji if configured
1308 if self.config.done_emoji and message_id:
1309 await self._add_reaction(message_id, self.config.done_emoji)
1310
1311 buf = self._stream_bufs.pop(chat_id, None)
1312 if not buf or not buf.text:
1313 return
1314 # Try to finalize via streaming card; if that fails (e.g.
1315 # streaming mode was closed by Feishu due to timeout), fall
1316 # back to sending a regular interactive card.
1317 if buf.card_id:
1318 buf.sequence += 1
1319 ok = await loop.run_in_executor(
1320 None,
1321 self._stream_update_text_sync,
1322 buf.card_id,
1323 buf.text,
1324 buf.sequence,
1325 )
1326 if ok:
1327 buf.sequence += 1
1328 await loop.run_in_executor(
1329 None,
1330 self._close_streaming_mode_sync,
1331 buf.card_id,
1332 buf.sequence,
1333 )
1334 return
1335 logger.warning(
1336 "Streaming card {} final update failed, falling back to regular card",
1337 buf.card_id,
1338 )
1339 for chunk in self._split_elements_by_table_limit(
1340 self._build_card_elements(buf.text)
1341 ):
1342 card = json.dumps(
1343 {"config": {"wide_screen_mode": True}, "elements": chunk},
1344 ensure_ascii=False,
1345 )
1346 await loop.run_in_executor(
1347 None, self._send_message_sync, rid_type, chat_id, "interactive", card
1348 )
1349 return
1350
1351 # --- accumulate delta ---
1352 buf = self._stream_bufs.get(chat_id)
1353 if buf is None:
1354 buf = _FeishuStreamBuf()
1355 self._stream_bufs[chat_id] = buf
1356 buf.text += delta
1357 if not buf.text.strip():
1358 return
1359
1360 now = time.monotonic()
1361 if buf.card_id is None:
1362 card_id = await loop.run_in_executor(
1363 None, self._create_streaming_card_sync, rid_type, chat_id
1364 )
1365 if card_id:
1366 buf.card_id = card_id
1367 buf.sequence = 1
1368 await loop.run_in_executor(
1369 None, self._stream_update_text_sync, card_id, buf.text, 1
1370 )
1371 buf.last_edit = now
1372 elif (now - buf.last_edit) >= self._STREAM_EDIT_INTERVAL:
1373 buf.sequence += 1
1374 await loop.run_in_executor(
1375 None, self._stream_update_text_sync, buf.card_id, buf.text, buf.sequence
1376 )
1377 buf.last_edit = now
1378
1379 async def send(self, msg: OutboundMessage) -> None:
1380 """Send a message through Feishu, including media (images/files) if present."""
1381 if not self._client:
1382 logger.warning("Feishu client not initialized")
1383 return
1384
1385 try:
1386 receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
1387 loop = asyncio.get_running_loop()
1388
1389 # Handle tool hint messages. When a streaming card is active for
1390 # this chat, inline the hint into the card instead of sending a
1391 # separate message so the user experience stays cohesive.
1392 if msg.metadata.get("_tool_hint"):
1393 hint = (msg.content or "").strip()
1394 if not hint:
1395 return
1396 buf = self._stream_bufs.get(msg.chat_id)
1397 if buf and buf.card_id:
1398 # Delegate to send_delta so tool hints get the same
1399 # throttling (and card creation) as regular text deltas.
1400 await self.send_delta(
1401 msg.chat_id,
1402 "\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
1403 )
1404 return
1405 # No active streaming card — send as a regular
1406 # interactive card with the same 🔧 prefix style.
1407 card = json.dumps(
1408 {"config": {"wide_screen_mode": True}, "elements": [
1409 {"tag": "markdown", "content": self._format_tool_hint_delta(hint)},
1410 ]},
1411 ensure_ascii=False,
1412 )
1413 await loop.run_in_executor(
1414 None, self._send_message_sync, receive_id_type, msg.chat_id, "interactive", card
1415 )
1416 return
1417
1418 # Determine whether the first message should quote the user's message.
1419 # Only the very first send (media or text) in this call uses reply; subsequent
1420 # chunks/media fall back to plain create to avoid redundant quote bubbles.
1421 reply_message_id: str | None = None
1422 if self.config.reply_to_message and not msg.metadata.get("_progress", False):
1423 reply_message_id = msg.metadata.get("message_id") or None
1424 # For topic group messages, always reply to keep context in thread
1425 elif msg.metadata.get("thread_id"):
1426 reply_message_id = (
1427 msg.metadata.get("root_id") or msg.metadata.get("message_id") or None
1428 )
1429
1430 first_send = True # tracks whether the reply has already been used
1431
1432 def _do_send(m_type: str, content: str) -> None:
1433 """Send via reply (first message) or create (subsequent)."""
1434 nonlocal first_send
1435 if reply_message_id and first_send:
1436 first_send = False
1437 ok = self._reply_message_sync(reply_message_id, m_type, content)
1438 if ok:
1439 return
1440 # Fall back to regular send if reply fails
1441 self._send_message_sync(receive_id_type, msg.chat_id, m_type, content)
1442
1443 for file_path in msg.media:
1444 if not os.path.isfile(file_path):
1445 logger.warning("Media file not found: {}", file_path)
1446 continue
1447 ext = os.path.splitext(file_path)[1].lower()
1448 if ext in self._IMAGE_EXTS:
1449 key = await loop.run_in_executor(None, self._upload_image_sync, file_path)
1450 if key:
1451 await loop.run_in_executor(
1452 None,
1453 _do_send,
1454 "image",
1455 json.dumps({"image_key": key}, ensure_ascii=False),
1456 )
1457 else:
1458 key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
1459 if key:
1460 # Use msg_type "audio" for audio, "video" for video, "file" for documents.
1461 # Feishu requires these specific msg_types for inline playback.
1462 # Note: "media" is only valid as a tag inside "post" messages, not as a standalone msg_type.
1463 if ext in self._AUDIO_EXTS:
1464 media_type = "audio"
1465 elif ext in self._VIDEO_EXTS:
1466 media_type = "video"
1467 else:
1468 media_type = "file"
1469 await loop.run_in_executor(
1470 None,
1471 _do_send,
1472 media_type,
1473 json.dumps({"file_key": key}, ensure_ascii=False),
1474 )
1475
1476 if msg.content and msg.content.strip():
1477 fmt = self._detect_msg_format(msg.content)
1478
1479 if fmt == "text":
1480 # Short plain text – send as simple text message
1481 text_body = json.dumps({"text": msg.content.strip()}, ensure_ascii=False)
1482 await loop.run_in_executor(None, _do_send, "text", text_body)
1483
1484 elif fmt == "post":
1485 # Medium content with links – send as rich-text post
1486 post_body = self._markdown_to_post(msg.content)
1487 await loop.run_in_executor(None, _do_send, "post", post_body)
1488
1489 else:
1490 # Complex / long content – send as interactive card
1491 elements = self._build_card_elements(msg.content)
1492 for chunk in self._split_elements_by_table_limit(elements):
1493 card = {"config": {"wide_screen_mode": True}, "elements": chunk}
1494 await loop.run_in_executor(
1495 None,
1496 _do_send,
1497 "interactive",
1498 json.dumps(card, ensure_ascii=False),
1499 )
1500
1501 except Exception as e:
1502 logger.error("Error sending Feishu message: {}", e)
1503 raise
1504
1505 def _on_message_sync(self, data: Any) -> None:
1506 """
1507 Sync handler for incoming messages (called from WebSocket thread).
1508 Schedules async handling in the main event loop.
1509 """
1510 if self._loop and self._loop.is_running():
1511 asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop)
1512
1513 async def _on_message(self, data: P2ImMessageReceiveV1) -> None:
1514 """Handle incoming message from Feishu."""
1515 try:
1516 event = data.event
1517 message = event.message
1518 sender = event.sender
1519
1520 logger.debug("Feishu raw message: {}", message.content)
1521 logger.debug("Feishu mentions: {}", getattr(message, "mentions", None))
1522
1523 # Deduplication check
1524 message_id = message.message_id
1525 if message_id in self._processed_message_ids:
1526 return
1527 self._processed_message_ids[message_id] = None
1528
1529 # Trim cache
1530 while len(self._processed_message_ids) > 1000:
1531 self._processed_message_ids.popitem(last=False)
1532
1533 # Skip bot messages
1534 if sender.sender_type == "bot":
1535 return
1536
1537 sender_id = sender.sender_id.open_id if sender.sender_id else "unknown"
1538 chat_id = message.chat_id
1539 chat_type = message.chat_type
1540 msg_type = message.message_type
1541
1542 if chat_type == "group" and not self._is_group_message_for_bot(message):
1543 logger.debug("Feishu: skipping group message (not mentioned)")
1544 return
1545
1546 # Add reaction
1547 reaction_id = await self._add_reaction(message_id, self.config.react_emoji)
1548
1549 # Parse content
1550 content_parts = []
1551 media_paths = []
1552
1553 try:
1554 content_json = json.loads(message.content) if message.content else {}
1555 except json.JSONDecodeError:
1556 content_json = {}
1557
1558 if msg_type == "text":
1559 text = content_json.get("text", "")
1560 if text:
1561 mentions = getattr(message, "mentions", None)
1562 text = self._resolve_mentions(text, mentions)
1563 content_parts.append(text)
1564
1565 elif msg_type == "post":
1566 text, image_keys = _extract_post_content(content_json)
1567 if text:
1568 content_parts.append(text)
1569 # Download images embedded in post
1570 for img_key in image_keys:
1571 file_path, content_text = await self._download_and_save_media(
1572 "image", {"image_key": img_key}, message_id
1573 )
1574 if file_path:
1575 media_paths.append(file_path)
1576 content_parts.append(content_text)
1577
1578 elif msg_type in ("image", "audio", "file", "media"):
1579 file_path, content_text = await self._download_and_save_media(
1580 msg_type, content_json, message_id
1581 )
1582 if file_path:
1583 media_paths.append(file_path)
1584
1585 if msg_type == "audio" and file_path:
1586 transcription = await self.transcribe_audio(file_path)
1587 if transcription:
1588 content_text = f"[transcription: {transcription}]"
1589
1590 content_parts.append(content_text)
1591
1592 elif msg_type in (
1593 "share_chat",
1594 "share_user",
1595 "interactive",
1596 "share_calendar_event",
1597 "system",
1598 "merge_forward",
1599 ):
1600 # Handle share cards and interactive messages
1601 text = _extract_share_card_content(content_json, msg_type)
1602 if text:
1603 content_parts.append(text)
1604
1605 else:
1606 content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
1607
1608 # Extract reply context (parent/root message IDs)
1609 parent_id = getattr(message, "parent_id", None) or None
1610 root_id = getattr(message, "root_id", None) or None
1611 thread_id = getattr(message, "thread_id", None) or None
1612
1613 # Prepend quoted message text when the user replied to another message
1614 if parent_id and self._client:
1615 loop = asyncio.get_running_loop()
1616 reply_ctx = await loop.run_in_executor(
1617 None, self._get_message_content_sync, parent_id
1618 )
1619 if reply_ctx:
1620 content_parts.insert(0, reply_ctx)
1621
1622 content = "\n".join(content_parts) if content_parts else ""
1623
1624 if not content and not media_paths:
1625 return
1626
1627 # Forward to message bus
1628 reply_to = chat_id if chat_type == "group" else sender_id
1629 await self._handle_message(
1630 sender_id=sender_id,
1631 chat_id=reply_to,
1632 content=content,
1633 media=media_paths,
1634 metadata={
1635 "message_id": message_id,
1636 "reaction_id": reaction_id,
1637 "chat_type": chat_type,
1638 "msg_type": msg_type,
1639 "parent_id": parent_id,
1640 "root_id": root_id,
1641 "thread_id": thread_id,
1642 },
1643 )
1644
1645 except Exception as e:
1646 logger.error("Error processing Feishu message: {}", e)
1647
1648 def _on_reaction_created(self, data: Any) -> None:
1649 """Ignore reaction events so they do not generate SDK noise."""
1650 pass
1651
1652 def _on_reaction_deleted(self, data: Any) -> None:
1653 """Ignore reaction deleted events so they do not generate SDK noise."""
1654 pass
1655
1656 def _on_message_read(self, data: Any) -> None:
1657 """Ignore read events so they do not generate SDK noise."""
1658 pass
1659
1660 def _on_bot_p2p_chat_entered(self, data: Any) -> None:
1661 """Ignore p2p-enter events when a user opens a bot chat."""
1662 logger.debug("Bot entered p2p chat (user opened chat window)")
1663 pass
1664
1665 @staticmethod
1666 def _format_tool_hint_lines(tool_hint: str) -> str:
1667 """Split tool hints across lines on top-level call separators only."""
1668 parts: list[str] = []
1669 buf: list[str] = []
1670 depth = 0
1671 in_string = False
1672 quote_char = ""
1673 escaped = False
1674
1675 for i, ch in enumerate(tool_hint):
1676 buf.append(ch)
1677
1678 if in_string:
1679 if escaped:
1680 escaped = False
1681 elif ch == "\\":
1682 escaped = True
1683 elif ch == quote_char:
1684 in_string = False
1685 continue
1686
1687 if ch in {'"', "'"}:
1688 in_string = True
1689 quote_char = ch
1690 continue
1691
1692 if ch == "(":
1693 depth += 1
1694 continue
1695
1696 if ch == ")" and depth > 0:
1697 depth -= 1
1698 continue
1699
1700 if ch == "," and depth == 0:
1701 next_char = tool_hint[i + 1] if i + 1 < len(tool_hint) else ""
1702 if next_char == " ":
1703 parts.append("".join(buf).rstrip())
1704 buf = []
1705
1706 if buf:
1707 parts.append("".join(buf).strip())
1708
1709 return "\n".join(part for part in parts if part)
1710
1711 def _format_tool_hint_delta(self, tool_hint: str) -> str:
1712 """Format a tool hint string with the 🔧 prefix for each line."""
1713 lines = self.__class__._format_tool_hint_lines(tool_hint).split("\n")
1714 return "\n".join(
1715 f"{self.config.tool_hint_prefix} {ln}" for ln in lines if ln.strip()
1716 )
1717
1717 lines PYTHON