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,216 @@ 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 (
330+ self , receive_id_type : str , chat_id : str , card : dict [str , Any ]
331+ ) -> Optional [str ]:
332+ print ("[Feishu] Building CreateMessageRequest..." )
333+ request = (
334+ CreateMessageRequest .builder ()
335+ .receive_id_type (receive_id_type )
336+ .request_body (
337+ CreateMessageRequestBody .builder ()
338+ .receive_id (chat_id )
339+ .msg_type ("interactive" )
340+ .content (json .dumps (card , ensure_ascii = False ))
341+ .build ()
342+ )
343+ .build ()
344+ )
345+ print ("[Feishu] Calling im.v1.message.create()..." )
346+ response = self ._client .im .v1 .message .create (request )
347+ print (
348+ f"[Feishu] Response received: success={ response .success ()} , code={ response .code } , msg={ response .msg } "
349+ )
350+ if response .success ():
351+ message_id = response .data .message_id
352+ print (f"[Feishu] Message sent successfully, message_id: { message_id } " )
353+ return message_id
354+
355+ print (f"[Feishu] Send failed: { response .code } { response .msg } " )
356+ return None
357+
358+ def _create_reply (self , parent_message_id : str , card : dict [str , Any ]) -> Optional [str ]:
359+ request = (
360+ ReplyMessageRequest .builder ()
361+ .message_id (parent_message_id )
362+ .request_body (
363+ ReplyMessageRequestBody .builder ()
364+ .msg_type ("interactive" )
365+ .content (json .dumps (card , ensure_ascii = False ))
366+ .reply_in_thread (True )
367+ .build ()
368+ )
369+ .build ()
370+ )
371+ print ("[Feishu] Calling im.v1.message.reply()..." )
372+ response = self ._client .im .v1 .message .reply (request )
373+ print (
374+ f"[Feishu] Reply response: success={ response .success ()} , code={ response .code } , msg={ response .msg } "
375+ )
376+ if response .success ():
377+ message_id = response .data .message_id
378+ print (f"[Feishu] Reply sent successfully, message_id: { message_id } " )
379+ return message_id
380+
381+ print (f"[Feishu] Reply failed: { response .code } { response .msg } " )
382+ return None
383+
384+ def _build_notification_card (
385+ self ,
386+ task_id : int ,
387+ task : dict [str , Any ],
388+ is_completed : bool ,
389+ body_text : str ,
390+ ) -> dict [str , Any ]:
391+ clean_body = (body_text or "" ).strip () or ("Done." if is_completed else "Unknown error" )
392+ summary = self ._truncate_text (clean_body .splitlines ()[0 ], 120 ) if clean_body else ""
393+ elements = self ._build_result_elements (body_text = clean_body )
394+
395+ if not is_completed :
396+ elements .append (
397+ {
398+ "tag" : "markdown" ,
399+ "content" : f"`/status { task_id } ` for full details" ,
400+ }
401+ )
402+
403+ return {
404+ "schema" : "2.0" ,
405+ "config" : {
406+ "wide_screen_mode" : True ,
407+ "enable_forward" : True ,
408+ "width_mode" : "fill" ,
409+ "summary" : {"content" : summary },
410+ },
411+ "body" : {
412+ "elements" : elements ,
413+ },
414+ }
415+
416+ def _build_result_elements (self , body_text : str ) -> list [dict [str , Any ]]:
417+ clean_body = (body_text or "" ).strip () or "Done."
418+ if len (clean_body ) <= 1200 :
419+ return [
420+ {
421+ "tag" : "markdown" ,
422+ "content" : clean_body ,
423+ }
424+ ]
425+
426+ preview = self ._truncate_text (clean_body , 500 )
427+ full_text = self ._truncate_text (clean_body , 8000 )
428+ return [
429+ {
430+ "tag" : "markdown" ,
431+ "content" : preview ,
432+ },
433+ {
434+ "tag" : "collapsible_panel" ,
435+ "expanded" : False ,
436+ "header" : {
437+ "title" : {
438+ "tag" : "plain_text" ,
439+ "content" : "展开查看完整结果" ,
440+ }
441+ },
442+ "elements" : [
443+ {
444+ "tag" : "markdown" ,
445+ "content" : full_text ,
446+ }
447+ ],
448+ },
449+ ]
450+
451+ def _build_legacy_markdown_card (self , content : str ) -> dict [str , Any ]:
452+ return {
453+ "config" : {"wide_screen_mode" : True },
454+ "elements" : [{"tag" : "markdown" , "content" : content }],
455+ }
456+
457+ def _truncate_text (self , text : str , limit : int ) -> str :
458+ normalized = text .replace ("\r \n " , "\n " ).strip ()
459+ if len (normalized ) <= limit :
460+ return normalized
461+ return normalized [:limit ].rstrip () + "\n …(truncated)"
462+
463+ def _escape_feishu_markdown (self , text : str ) -> str :
464+ return text .replace ("\\ " , "\\ \\ " )
465+
338466 def _add_reaction (self , message_id : str , emoji_type : str = "THUMBSUP" ):
339467 """Add an emoji reaction in a background thread (non-blocking)."""
340468 if not self ._client or not FEISHU_AVAILABLE :
0 commit comments