1717import threading
1818from datetime import datetime , timedelta , timezone
1919from pathlib import Path
20- from typing import TYPE_CHECKING , Optional
20+ from typing import TYPE_CHECKING , Any , Optional
2121
2222from taskboard_bus import Channel , MessageBus , OutboundMessage , OutboundMessageType
2323
@@ -210,6 +210,13 @@ def send(self, msg: OutboundMessage) -> None:
210210 ]
211211 content = error_text
212212
213+ card = self ._build_notification_card (
214+ task_id = task_id ,
215+ task = task ,
216+ is_completed = is_completed ,
217+ body_text = content ,
218+ )
219+
213220 # Try to reply in thread if we have an origin message
214221 with self ._origin_lock :
215222 origin = self ._task_origin .get (task_id )
@@ -220,13 +227,13 @@ def send(self, msg: OutboundMessage) -> None:
220227 # Add emoji reaction to the message that triggered the task (or resume)
221228 emoji = "DONE" if is_completed else "Cry"
222229 self ._add_reaction (reaction_msg_id , emoji )
223- sent_id = self ._reply_message (root_msg_id , content )
230+ sent_id = self ._reply_message (root_msg_id , content , card = card )
224231
225232 # Fallback: send to default chat if no origin or reply failed
226233 if not sent_id :
227234 chat_id = self .db .get_setting ("feishu_default_chat_id" )
228235 if chat_id :
229- sent_id = self ._send_message (chat_id , content )
236+ sent_id = self ._send_message (chat_id , content , card = card , fallback_content = content )
230237
231238 if sent_id :
232239 print (f"[Feishu] Notification sent successfully, message_id: { sent_id } " )
@@ -246,95 +253,214 @@ def _on_outbound(self, msg: OutboundMessage) -> None:
246253
247254 # ── outbound: low-level send ──────────────────────────────────
248255
249- def _send_message (self , chat_id : str , content : str ) -> Optional [str ]:
250- """Send a markdown card to chat_id. Returns the sent message_id or None."""
256+ def _send_message (
257+ self ,
258+ chat_id : str ,
259+ content : str ,
260+ card : Optional [dict [str , Any ]] = None ,
261+ fallback_content : Optional [str ] = None ,
262+ ) -> Optional [str ]:
263+ """Send a card to chat_id. Falls back to the legacy markdown card on failure."""
251264 print (f"[Feishu] _send_message called, chat_id: { chat_id } , content length: { len (content )} " )
252265 if not self ._client :
253266 print ("[Feishu] Client not initialized in _send_message" )
254267 return None
255268 try :
256269 receive_id_type = "chat_id" if chat_id .startswith ("oc_" ) else "open_id"
257270 print (f"[Feishu] receive_id_type: { receive_id_type } " )
258- card = {
259- "config" : {"wide_screen_mode" : True },
260- "elements" : [{"tag" : "markdown" , "content" : content }],
261- }
262- print ("[Feishu] Building CreateMessageRequest..." )
263- request = (
264- CreateMessageRequest .builder ()
265- .receive_id_type (receive_id_type )
266- .request_body (
267- CreateMessageRequestBody .builder ()
268- .receive_id (chat_id )
269- .msg_type ("interactive" )
270- .content (json .dumps (card , ensure_ascii = False ))
271- .build ()
272- )
273- .build ()
271+ card_payload = card or self ._build_legacy_markdown_card (content )
272+ message_id = self ._create_message (
273+ receive_id_type = receive_id_type ,
274+ chat_id = chat_id ,
275+ card = card_payload ,
274276 )
275- print ("[Feishu] Calling im.v1.message.create()..." )
276- response = self ._client .im .v1 .message .create (request )
277- print (
278- f"[Feishu] Response received: success={ response .success ()} , code={ response .code } , msg={ response .msg } "
279- )
280- if response .success ():
281- message_id = response .data .message_id
282- print (f"[Feishu] Message sent successfully, message_id: { message_id } " )
277+ if message_id :
283278 return message_id
284- else :
285- print (f"[Feishu] Send failed: { response .code } { response .msg } " )
286- return None
279+
280+ if card is not None :
281+ legacy_content = fallback_content or content
282+ print ("[Feishu] Structured card send failed, retrying with legacy markdown card" )
283+ return self ._create_message (
284+ receive_id_type = receive_id_type ,
285+ chat_id = chat_id ,
286+ card = self ._build_legacy_markdown_card (legacy_content ),
287+ )
288+ return None
287289 except Exception as e :
288290 print (f"[Feishu] Error sending message: { e } " )
289291 import traceback
290292
291293 traceback .print_exc ()
292294 return None
293295
294- def _reply_message (self , parent_message_id : str , content : str ) -> Optional [str ]:
295- """Reply to a specific message (thread-style). Returns the sent message_id or None."""
296+ def _reply_message (
297+ self ,
298+ parent_message_id : str ,
299+ content : str ,
300+ card : Optional [dict [str , Any ]] = None ,
301+ ) -> Optional [str ]:
302+ """Reply to a specific message (thread-style). Falls back to the legacy markdown card."""
296303 print (
297304 f"[Feishu] _reply_message called, parent_message_id: { parent_message_id } , content length: { len (content )} "
298305 )
299306 if not self ._client :
300307 print ("[Feishu] Client not initialized in _reply_message" )
301308 return None
302309 try :
303- card = {
304- "config" : {"wide_screen_mode" : True },
305- "elements" : [{"tag" : "markdown" , "content" : content }],
306- }
307- request = (
308- ReplyMessageRequest .builder ()
309- .message_id (parent_message_id )
310- .request_body (
311- ReplyMessageRequestBody .builder ()
312- .msg_type ("interactive" )
313- .content (json .dumps (card , ensure_ascii = False ))
314- .reply_in_thread (True )
315- .build ()
316- )
317- .build ()
318- )
319- print ("[Feishu] Calling im.v1.message.reply()..." )
320- response = self ._client .im .v1 .message .reply (request )
321- print (
322- f"[Feishu] Reply response: success={ response .success ()} , code={ response .code } , msg={ response .msg } "
323- )
324- if response .success ():
325- message_id = response .data .message_id
326- print (f"[Feishu] Reply sent successfully, message_id: { message_id } " )
310+ reply_card = card or self ._build_legacy_markdown_card (content )
311+ message_id = self ._create_reply (parent_message_id = parent_message_id , card = reply_card )
312+ if message_id :
327313 return message_id
328- else :
329- print (f"[Feishu] Reply failed: { response .code } { response .msg } " )
330- return None
314+
315+ if card is not None :
316+ print ("[Feishu] Structured card reply failed, retrying with legacy markdown card" )
317+ return self ._create_reply (
318+ parent_message_id = parent_message_id ,
319+ card = self ._build_legacy_markdown_card (content ),
320+ )
321+ return None
331322 except Exception as e :
332323 print (f"[Feishu] Error replying to message: { e } " )
333324 import traceback
334325
335326 traceback .print_exc ()
336327 return None
337328
329+ def _create_message (self , receive_id_type : str , chat_id : str , card : dict [str , Any ]) -> Optional [str ]:
330+ print ("[Feishu] Building CreateMessageRequest..." )
331+ request = (
332+ CreateMessageRequest .builder ()
333+ .receive_id_type (receive_id_type )
334+ .request_body (
335+ CreateMessageRequestBody .builder ()
336+ .receive_id (chat_id )
337+ .msg_type ("interactive" )
338+ .content (json .dumps (card , ensure_ascii = False ))
339+ .build ()
340+ )
341+ .build ()
342+ )
343+ print ("[Feishu] Calling im.v1.message.create()..." )
344+ response = self ._client .im .v1 .message .create (request )
345+ print (
346+ f"[Feishu] Response received: success={ response .success ()} , code={ response .code } , msg={ response .msg } "
347+ )
348+ if response .success ():
349+ message_id = response .data .message_id
350+ print (f"[Feishu] Message sent successfully, message_id: { message_id } " )
351+ return message_id
352+
353+ print (f"[Feishu] Send failed: { response .code } { response .msg } " )
354+ return None
355+
356+ def _create_reply (self , parent_message_id : str , card : dict [str , Any ]) -> Optional [str ]:
357+ request = (
358+ ReplyMessageRequest .builder ()
359+ .message_id (parent_message_id )
360+ .request_body (
361+ ReplyMessageRequestBody .builder ()
362+ .msg_type ("interactive" )
363+ .content (json .dumps (card , ensure_ascii = False ))
364+ .reply_in_thread (True )
365+ .build ()
366+ )
367+ .build ()
368+ )
369+ print ("[Feishu] Calling im.v1.message.reply()..." )
370+ response = self ._client .im .v1 .message .reply (request )
371+ print (
372+ f"[Feishu] Reply response: success={ response .success ()} , code={ response .code } , msg={ response .msg } "
373+ )
374+ if response .success ():
375+ message_id = response .data .message_id
376+ print (f"[Feishu] Reply sent successfully, message_id: { message_id } " )
377+ return message_id
378+
379+ print (f"[Feishu] Reply failed: { response .code } { response .msg } " )
380+ return None
381+
382+ def _build_notification_card (
383+ self ,
384+ task_id : int ,
385+ task : dict [str , Any ],
386+ is_completed : bool ,
387+ body_text : str ,
388+ ) -> dict [str , Any ]:
389+ clean_body = (body_text or "" ).strip () or ("Done." if is_completed else "Unknown error" )
390+ summary = self ._truncate_text (clean_body .splitlines ()[0 ], 120 ) if clean_body else ""
391+ elements = self ._build_result_elements (body_text = clean_body )
392+
393+ if not is_completed :
394+ elements .append (
395+ {
396+ "tag" : "markdown" ,
397+ "content" : f"`/status { task_id } ` for full details" ,
398+ }
399+ )
400+
401+ return {
402+ "schema" : "2.0" ,
403+ "config" : {
404+ "wide_screen_mode" : True ,
405+ "enable_forward" : True ,
406+ "width_mode" : "fill" ,
407+ "summary" : {"content" : summary },
408+ },
409+ "body" : {
410+ "elements" : elements ,
411+ },
412+ }
413+
414+ def _build_result_elements (self , body_text : str ) -> list [dict [str , Any ]]:
415+ clean_body = (body_text or "" ).strip () or "Done."
416+ if len (clean_body ) <= 1200 :
417+ return [
418+ {
419+ "tag" : "markdown" ,
420+ "content" : clean_body ,
421+ }
422+ ]
423+
424+ preview = self ._truncate_text (clean_body , 500 )
425+ full_text = self ._truncate_text (clean_body , 8000 )
426+ return [
427+ {
428+ "tag" : "markdown" ,
429+ "content" : preview ,
430+ },
431+ {
432+ "tag" : "collapsible_panel" ,
433+ "expanded" : False ,
434+ "header" : {
435+ "title" : {
436+ "tag" : "plain_text" ,
437+ "content" : "展开查看完整结果" ,
438+ }
439+ },
440+ "elements" : [
441+ {
442+ "tag" : "markdown" ,
443+ "content" : full_text ,
444+ }
445+ ],
446+ },
447+ ]
448+
449+ def _build_legacy_markdown_card (self , content : str ) -> dict [str , Any ]:
450+ return {
451+ "config" : {"wide_screen_mode" : True },
452+ "elements" : [{"tag" : "markdown" , "content" : content }],
453+ }
454+
455+ def _truncate_text (self , text : str , limit : int ) -> str :
456+ normalized = text .replace ("\r \n " , "\n " ).strip ()
457+ if len (normalized ) <= limit :
458+ return normalized
459+ return normalized [:limit ].rstrip () + "\n …(truncated)"
460+
461+ def _escape_feishu_markdown (self , text : str ) -> str :
462+ return text .replace ("\\ " , "\\ \\ " )
463+
338464 def _add_reaction (self , message_id : str , emoji_type : str = "THUMBSUP" ):
339465 """Add an emoji reaction in a background thread (non-blocking)."""
340466 if not self ._client or not FEISHU_AVAILABLE :
0 commit comments