3838 NotificationTypes = None # type: ignore[assignment]
3939
4040from .agent_interface import AgentInterface
41+ from .email_channel_compat import is_email_notification
4142from .token_cache import get_cached_agentic_token
4243
4344logger = logging .getLogger (__name__ )
@@ -288,26 +289,21 @@ async def handle_agent_notification_activity(
288289 getattr (conversation , "id" , "" ) or f"notification:{ notification_type } "
289290 )
290291
291- is_email = (
292- NotificationTypes is not None
293- and notification_type == NotificationTypes .EMAIL_NOTIFICATION
294- )
295- is_wpx_comment = (
296- NotificationTypes is not None
297- and notification_type == NotificationTypes .WPX_COMMENT
298- )
292+ is_email = is_email_notification (notification_activity )
293+ is_wpx_comment = self ._is_wpx_comment_notification (notification_type )
299294
300295 if is_email :
301- email = getattr (notification_activity , "email" , None )
302- email_body = (
303- getattr ( email , "html_body" , "" ) or getattr (email , "body " , "" )
304- if email
305- else ""
296+ from_prop = getattr (
297+ notification_activity , "from_property" , None
298+ ) or getattr (notification_activity , "from " , None )
299+ from_email = (
300+ getattr ( from_prop , "id" , "" ) or getattr ( from_prop , "name" , "" )
306301 )
302+ email_details = self ._serialize_notification (notification_activity )
307303 msg = (
308- getattr ( notification_activity , "text" , "" )
309- or "You have received the following email. Please follow any "
310- f"instructions in it. { email_body } "
304+ "You received a new email. Please look at the email and return "
305+ "a response in html format. "
306+ f"From: { from_email } \n Email details: \n { email_details } "
311307 )
312308 return await self ._invoke_responses_api (
313309 input_text = msg ,
@@ -319,40 +315,12 @@ async def handle_agent_notification_activity(
319315 ) or "Email notification processed."
320316
321317 if is_wpx_comment :
322- wpx = getattr (notification_activity , "wpx_comment" , None )
323- doc_id = getattr (wpx , "document_id" , "" ) if wpx else ""
324- comment_id = getattr (wpx , "initiating_comment_id" , "" ) if wpx else ""
325- drive_id = "default"
326- comment_text = getattr (notification_activity , "text" , "" ) or ""
327-
328- doc_message = (
329- f"You have a new comment on the document with id '{ doc_id } ', "
330- f"comment id '{ comment_id } ', drive id '{ drive_id } '. Please "
331- "retrieve the document as well as the comments and return it "
332- "in text format."
333- )
334- doc_content = await self ._invoke_responses_api (
335- input_text = doc_message ,
336- conversation_id = conversation_id ,
337- instructions = self .AGENT_PROMPT ,
338- auth = auth ,
339- auth_handler_name = auth_handler_name ,
340- context = context ,
341- )
342-
343- response_message = (
344- "You have received the following document content and "
345- "comments. Please refer to these when responding to "
346- f"comment '{ comment_text } '. { doc_content } "
318+ return await self ._handle_comment_notification (
319+ notification_activity ,
320+ auth ,
321+ auth_handler_name ,
322+ context ,
347323 )
348- return await self ._invoke_responses_api (
349- input_text = response_message ,
350- conversation_id = conversation_id ,
351- instructions = self .AGENT_PROMPT ,
352- auth = auth ,
353- auth_handler_name = auth_handler_name ,
354- context = context ,
355- ) or "Comment notification processed."
356324
357325 notification_message = (
358326 getattr (notification_activity , "text" , "" )
@@ -370,6 +338,230 @@ async def handle_agent_notification_activity(
370338 logger .exception ("Error processing notification" )
371339 return f"Sorry, I encountered an error processing the notification: { ex } "
372340
341+ async def _handle_comment_notification (
342+ self ,
343+ notification_activity : Any ,
344+ auth : Authorization ,
345+ auth_handler_name : Optional [str ],
346+ context : TurnContext ,
347+ ) -> str :
348+ logger .info ("Processing comment notification (Responses API)" )
349+
350+ comment = self ._get_comment_payload (notification_activity )
351+ if comment is None :
352+ logger .warning ("Comment notification received without WpxComment payload" )
353+ return ""
354+
355+ document_id = self ._get_first_value (comment , "document_id" , "documentId" )
356+ comment_id = self ._get_first_value (
357+ comment ,
358+ "comment_id" ,
359+ "commentId" ,
360+ "initiating_comment_id" ,
361+ "initiatingCommentId" ,
362+ )
363+ parent_comment_id = self ._get_first_value (
364+ comment ,
365+ "parent_comment_id" ,
366+ "parentCommentId" ,
367+ )
368+
369+ content_url = self ._get_first_attachment_content_url (context )
370+ if not content_url :
371+ logger .warning (
372+ "Comment notification for CommentId=%s on DocumentId=%s has no attachment ContentUrl" ,
373+ comment_id ,
374+ document_id ,
375+ )
376+ return ""
377+
378+ product_label , mcp_server_name = self ._infer_product_from_activity (
379+ context .activity ,
380+ content_url ,
381+ )
382+ from_prop = getattr (
383+ notification_activity , "from_property" , None
384+ ) or getattr (notification_activity , "from" , None )
385+ commenter = (
386+ getattr (from_prop , "name" , "" )
387+ or getattr (from_prop , "id" , "" )
388+ or "the commenter"
389+ )
390+ comment_text = (
391+ getattr (context .activity , "text" , "" )
392+ or getattr (notification_activity , "text" , "" )
393+ or ""
394+ ).strip ()
395+ comment_snippet = comment_text or "(no comment text)"
396+ document_id_for_prompt = document_id or "unknown-doc"
397+ comment_id_for_prompt = comment_id or "unknown-comment"
398+ parent_comment = parent_comment_id or "(none - this is a top-level comment)"
399+ conversation_id = f"comment:{ document_id_for_prompt } :{ comment_id_for_prompt } "
400+
401+ prompt = f"""
402+ You have been @-mentioned in a { product_label } comment and must reply to it.
403+
404+ Use the { mcp_server_name } MCP tools to do the following, in order:
405+ 1. Call GetDocumentContent with the sharing URL below to read the document and
406+ locate the text that the comment refers to.
407+ 2. Call ReplyToComment with commentId="{ comment_id_for_prompt } " to post your
408+ reply directly on the thread. Do NOT respond via chat or email - the reply
409+ must be posted through the { mcp_server_name } ReplyToComment tool so it
410+ shows up on the comment thread in the document.
411+
412+ Keep the reply concise, helpful, and grounded in the actual document content.
413+ Format the reply as plain text because the comment thread does not render HTML.
414+
415+ Document URL: { content_url }
416+ DocumentId: { document_id_for_prompt }
417+ CommentId: { comment_id_for_prompt }
418+ ParentCommentId: { parent_comment }
419+ Commenter: { commenter }
420+ Comment text: { comment_snippet }
421+ """ .strip ()
422+
423+ response = await self ._invoke_responses_api (
424+ input_text = prompt ,
425+ conversation_id = conversation_id ,
426+ instructions = self .AGENT_PROMPT ,
427+ auth = auth ,
428+ auth_handler_name = auth_handler_name ,
429+ context = context ,
430+ )
431+
432+ logger .info (
433+ "Comment reply flow finished for %s CommentId=%s. Model output (for diagnostics only): %s" ,
434+ product_label ,
435+ comment_id_for_prompt ,
436+ response .strip () if response and response .strip () else "(empty)" ,
437+ )
438+ return ""
439+
440+ @staticmethod
441+ def _is_wpx_comment_notification (notification_type : Any ) -> bool :
442+ if (
443+ NotificationTypes is not None
444+ and notification_type == NotificationTypes .WPX_COMMENT
445+ ):
446+ return True
447+
448+ value = getattr (notification_type , "value" , notification_type )
449+ normalized = str (value or "" ).lower ()
450+ return "comment" in normalized and (
451+ "wpx" in normalized
452+ or "word" in normalized
453+ or "excel" in normalized
454+ or "powerpoint" in normalized
455+ )
456+
457+ @staticmethod
458+ def _get_comment_payload (notification_activity : Any ) -> Any :
459+ for name in (
460+ "wpx_comment_notification" ,
461+ "wpx_comment" ,
462+ "wpxCommentNotification" ,
463+ "wpxComment" ,
464+ ):
465+ value = FoundryDigitalWorkerAgent ._get_first_value (
466+ notification_activity ,
467+ name ,
468+ )
469+ if value :
470+ return value
471+ return None
472+
473+ @staticmethod
474+ def _get_first_attachment_content_url (context : TurnContext ) -> str :
475+ activity = getattr (context , "activity" , None )
476+ attachments = getattr (activity , "attachments" , None ) or []
477+ for attachment in attachments :
478+ content_url = FoundryDigitalWorkerAgent ._get_first_value (
479+ attachment ,
480+ "content_url" ,
481+ "contentUrl" ,
482+ )
483+ if content_url :
484+ return content_url
485+ return ""
486+
487+ @staticmethod
488+ def _infer_product_from_activity (
489+ activity : Any ,
490+ content_url : str ,
491+ ) -> tuple [str , str ]:
492+ sub_channel = ""
493+ channel_id = getattr (activity , "channel_id" , None )
494+ if channel_id is not None :
495+ sub_channel = str (getattr (channel_id , "sub_channel" , "" ) or "" )
496+ if not sub_channel :
497+ _ , _ , sub_channel = str (channel_id ).partition (":" )
498+
499+ normalized = sub_channel .lower ()
500+ if "word" in normalized :
501+ return "Word" , "mcp_WordServer"
502+ if "excel" in normalized :
503+ return "Excel" , "mcp_ExcelServer"
504+ if "powerpoint" in normalized or "ppt" in normalized :
505+ return "PowerPoint" , "mcp_PowerPointServer"
506+
507+ return FoundryDigitalWorkerAgent ._infer_product_from_url (content_url )
508+
509+ @staticmethod
510+ def _infer_product_from_url (url : str ) -> tuple [str , str ]:
511+ lower = str (url ).lower ()
512+ if ".xlsx" in lower or ".xlsm" in lower or ".xlsb" in lower :
513+ return "Excel" , "mcp_ExcelServer"
514+ if ".pptx" in lower or ".ppt" in lower :
515+ return "PowerPoint" , "mcp_PowerPointServer"
516+ return "Word" , "mcp_WordServer"
517+
518+ @staticmethod
519+ def _get_first_value (value : Any , * names : str ) -> Any :
520+ if value is None :
521+ return ""
522+ if isinstance (value , dict ):
523+ for name in names :
524+ item = value .get (name )
525+ if item is not None and item != "" :
526+ return item
527+ return ""
528+
529+ for name in names :
530+ item = getattr (value , name , None )
531+ if item is not None and item != "" :
532+ return item
533+ return ""
534+
535+ @staticmethod
536+ def _serialize_notification (notification_activity : Any ) -> str :
537+ try :
538+ dump_json = getattr (notification_activity , "model_dump_json" , None )
539+ if callable (dump_json ):
540+ return dump_json (indent = 2 )
541+ except Exception as ex :
542+ logger .warning (
543+ "Failed to serialize notification via model_dump_json: %s" , ex
544+ )
545+
546+ try :
547+ return json .dumps (
548+ notification_activity ,
549+ default = FoundryDigitalWorkerAgent ._json_default ,
550+ indent = 2 ,
551+ )
552+ except Exception as ex :
553+ logger .warning ("Failed to serialize notification via json.dumps: %s" , ex )
554+ return str (notification_activity )
555+
556+ @staticmethod
557+ def _json_default (value : Any ) -> Any :
558+ model_dump = getattr (value , "model_dump" , None )
559+ if callable (model_dump ):
560+ return model_dump (mode = "json" , by_alias = True , exclude_none = True )
561+ if hasattr (value , "__dict__" ):
562+ return value .__dict__
563+ return str (value )
564+
373565 # ------------------------------------------------------------------
374566 # Azure OpenAI Responses API
375567 # ------------------------------------------------------------------
0 commit comments