Repository navigation
fix(runtime): deliver away notifications to IM #2859
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8716e6e
a66a890
c461907
747551f
fe73682
29fb8a8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,6 +45,11 @@ | |
| from app.api.ws.local_task_responses import ( | ||
| LocalTaskResponsesHandler, | ||
| emit_response_api_event, | ||
| is_runtime_terminal_event_type, | ||
| is_terminal_event, | ||
| local_task_terminal_status, | ||
| runtime_subtask_id, | ||
| runtime_terminal_event, | ||
| ) | ||
| from app.api.ws.wework_runtime_namespace import ( | ||
| PROJECT_CHAT_AGENT_CHUNK_EVENT, | ||
|
|
@@ -2209,8 +2214,120 @@ async def _forward_runtime_event( | |
| device_id=device_id, | ||
| payload=payload["payload"], | ||
| ) | ||
| await self._notify_runtime_event( | ||
| user_id=user_id, | ||
| device_id=device_id, | ||
| payload=payload["payload"], | ||
| ) | ||
| return {"success": True} | ||
|
|
||
| async def _notify_runtime_event( | ||
| self, | ||
| *, | ||
| user_id: int, | ||
| device_id: str, | ||
| payload: dict[str, Any], | ||
| ) -> None: | ||
|
Comment on lines
+2224
to
+2230
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Move the new IM-notification orchestration into a focused service instead of adding another roughly 100-line method to AGENTS.md reference: AGENTS.md:L45-L47 Useful? React with 👍 / 👎. |
||
| """Best-effort IM delivery for one terminal native Runtime event.""" | ||
|
|
||
| source = payload.get("source") | ||
| source_name = source.get("source") if isinstance(source, dict) else None | ||
| if source_name == "im": | ||
| return | ||
|
|
||
| local_task_id = str(payload.get("taskId") or "").strip() | ||
| event_type = str(payload.get("event_type") or "").strip() | ||
| if ( | ||
| not local_task_id | ||
| or not event_type | ||
| or not is_runtime_terminal_event_type(event_type) | ||
| ): | ||
| return | ||
|
|
||
| event_data = payload.get("data") | ||
| event_data = event_data if isinstance(event_data, dict) else {} | ||
| subtask_id = runtime_subtask_id(payload, device_id, local_task_id) | ||
| event = runtime_terminal_event( | ||
| event_type=event_type, | ||
| event_data=event_data, | ||
| subtask_id=subtask_id, | ||
| ) or self._local_task_responses.execution_event( | ||
| event_type=event_type, | ||
| event_data=event_data, | ||
| subtask_id=subtask_id, | ||
| message_id=None, | ||
| ) | ||
| if event is None or not is_terminal_event(event): | ||
| return | ||
|
|
||
| status = local_task_terminal_status(event) | ||
| result = event.result if isinstance(event.result, dict) else {} | ||
| content = str(result.get("value") or event.error or "") | ||
| if status == "COMPLETED" and not content.strip(): | ||
| logger.info( | ||
| "[RuntimeTaskNotification] Skipped empty Runtime reply: " | ||
| "user_id=%s device_id=%s local_task_id=%s event_type=%s", | ||
| user_id, | ||
| device_id, | ||
| local_task_id, | ||
| event_type, | ||
| ) | ||
| return | ||
|
|
||
| title = str( | ||
| payload.get("taskTitle") | ||
| or payload.get("task_title") | ||
| or event_data.get("title") | ||
| or local_task_id | ||
| ).strip() | ||
| try: | ||
| notification = ( | ||
| await im_notification_dispatcher.send_runtime_task_update_for_user( | ||
| user_id=user_id, | ||
| address={ | ||
| "deviceId": device_id, | ||
| "localTaskId": local_task_id, | ||
| }, | ||
| title=title or local_task_id, | ||
| status=status, | ||
| content=content, | ||
| source=str(source_name) if source_name else None, | ||
| ) | ||
| ) | ||
| except Exception: | ||
| logger.exception( | ||
| "[RuntimeTaskNotification] Runtime event IM delivery failed: " | ||
| "user_id=%s device_id=%s local_task_id=%s event_type=%s", | ||
| user_id, | ||
| device_id, | ||
| local_task_id, | ||
| event_type, | ||
| ) | ||
| return | ||
|
|
||
| notified = int(notification.get("sent") or 0) | ||
| results = _summarize_runtime_notification_results(notification) | ||
| if results and notified <= 0: | ||
| logger.warning( | ||
| "[RuntimeTaskNotification] Runtime event was not delivered: " | ||
| "user_id=%s device_id=%s local_task_id=%s event_type=%s results=%s", | ||
| user_id, | ||
| device_id, | ||
| local_task_id, | ||
| event_type, | ||
| results, | ||
| ) | ||
| else: | ||
| logger.info( | ||
| "[RuntimeTaskNotification] Runtime event dispatched: " | ||
| "user_id=%s device_id=%s local_task_id=%s event_type=%s sent=%s", | ||
| user_id, | ||
| device_id, | ||
| local_task_id, | ||
| event_type, | ||
| notified, | ||
| ) | ||
|
|
||
| async def _publish_task_completed_event( | ||
| self, | ||
| task_id: int, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -14,6 +14,9 @@ | |||||||||||||||||||||||||||||
| from app.models.im_session import IMPrivateSession | ||||||||||||||||||||||||||||||
| from app.models.kind import Kind | ||||||||||||||||||||||||||||||
| from app.services.im.session_service import im_session_service | ||||||||||||||||||||||||||||||
| from app.services.subscription.notification_service import ( | ||||||||||||||||||||||||||||||
| subscription_notification_service, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| from shared.utils.crypto import decrypt_sensitive_data | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||||||||||||||||
|
|
@@ -134,7 +137,7 @@ async def send_text( | |||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| config = _get_channel_config(channel) | ||||||||||||||||||||||||||||||
| if session.channel_type == "dingtalk": | ||||||||||||||||||||||||||||||
| return await self._send_dingtalk(session, config, text) | ||||||||||||||||||||||||||||||
| return await self._send_dingtalk(db, session, config, text) | ||||||||||||||||||||||||||||||
| if session.channel_type == "telegram": | ||||||||||||||||||||||||||||||
| return await self._send_telegram(session, config, text) | ||||||||||||||||||||||||||||||
| if session.channel_type == "discord": | ||||||||||||||||||||||||||||||
|
|
@@ -167,6 +170,10 @@ async def _runtime_notification_sessions( | |||||||||||||||||||||||||||||
| user_id: int, | ||||||||||||||||||||||||||||||
| address: dict[str, Any], | ||||||||||||||||||||||||||||||
| ) -> list[IMPrivateSession]: | ||||||||||||||||||||||||||||||
| settings = await im_session_service.get_global_notification_settings(user_id) | ||||||||||||||||||||||||||||||
| if not settings.enabled: | ||||||||||||||||||||||||||||||
| return [] | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| active_sessions = await im_session_service.list_active_runtime_task_sessions( | ||||||||||||||||||||||||||||||
| db, | ||||||||||||||||||||||||||||||
| user_id=user_id, | ||||||||||||||||||||||||||||||
|
|
@@ -185,8 +192,7 @@ async def _runtime_notification_sessions( | |||||||||||||||||||||||||||||
| if subscribed_sessions: | ||||||||||||||||||||||||||||||
| return _dedupe_sessions(subscribed_sessions) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| settings = await im_session_service.get_global_notification_settings(user_id) | ||||||||||||||||||||||||||||||
| if not settings.enabled or not settings.session_key: | ||||||||||||||||||||||||||||||
| if not settings.session_key: | ||||||||||||||||||||||||||||||
| return [] | ||||||||||||||||||||||||||||||
| if not await im_session_service.is_user_away_for_im_notifications(user_id): | ||||||||||||||||||||||||||||||
| return [] | ||||||||||||||||||||||||||||||
|
|
@@ -233,6 +239,7 @@ def _get_channel(self, db: Session, channel_id: int) -> Kind | None: | |||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| async def _send_dingtalk( | ||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||
| db: Session, | ||||||||||||||||||||||||||||||
| session: IMPrivateSession, | ||||||||||||||||||||||||||||||
| config: dict[str, Any], | ||||||||||||||||||||||||||||||
| text: str, | ||||||||||||||||||||||||||||||
|
|
@@ -249,9 +256,18 @@ async def _send_dingtalk( | |||||||||||||||||||||||||||||
| "error": "Missing DingTalk credentials", | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| recipient_id = await self._dingtalk_recipient_id(db, session) | ||||||||||||||||||||||||||||||
| if not recipient_id: | ||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||
| "success": False, | ||||||||||||||||||||||||||||||
| "channel_id": session.channel_id, | ||||||||||||||||||||||||||||||
| "channel_type": session.channel_type, | ||||||||||||||||||||||||||||||
| "error": "Missing DingTalk staff ID", | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| sender = DingTalkRobotSender(client_id, client_secret) | ||||||||||||||||||||||||||||||
| result = await sender.send_text_message( | ||||||||||||||||||||||||||||||
| user_ids=[session.sender_id], | ||||||||||||||||||||||||||||||
| user_ids=[recipient_id], | ||||||||||||||||||||||||||||||
| content=text, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||
|
|
@@ -260,6 +276,36 @@ async def _send_dingtalk( | |||||||||||||||||||||||||||||
| **result, | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| async def _dingtalk_recipient_id( | ||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||
| db: Session, | ||||||||||||||||||||||||||||||
| session: IMPrivateSession, | ||||||||||||||||||||||||||||||
| ) -> str: | ||||||||||||||||||||||||||||||
| recipient_id = str(session.proactive_recipient_id or "").strip() | ||||||||||||||||||||||||||||||
| if recipient_id: | ||||||||||||||||||||||||||||||
| return recipient_id | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| bindings = subscription_notification_service.get_user_im_bindings( | ||||||||||||||||||||||||||||||
| db, | ||||||||||||||||||||||||||||||
| user_id=session.user_id, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| binding = bindings.get(str(session.channel_id)) | ||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||
| binding is None | ||||||||||||||||||||||||||||||
| or binding.channel_type != session.channel_type | ||||||||||||||||||||||||||||||
| or str(binding.last_conversation_id or "").strip() | ||||||||||||||||||||||||||||||
| != session.conversation_id | ||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||
|
Comment on lines
+293
to
+298
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Validate the sender identity before DingTalk legacy backfill.
Proposed fix if (
binding is None
or binding.channel_type != session.channel_type
+ or str(binding.sender_id or "").strip()
+ != str(session.sender_id or "").strip()
or str(binding.last_conversation_id or "").strip()
!= session.conversation_id
):📝 Committable suggestion
Suggested change
📍 Affects 2 files
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| return "" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| recipient_id = str(binding.sender_staff_id or "").strip() | ||||||||||||||||||||||||||||||
| if not recipient_id: | ||||||||||||||||||||||||||||||
| return "" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| session.proactive_recipient_id = recipient_id | ||||||||||||||||||||||||||||||
| await im_session_service.save_session(session) | ||||||||||||||||||||||||||||||
| return recipient_id | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| async def _send_telegram( | ||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||
| session: IMPrivateSession, | ||||||||||||||||||||||||||||||
|
|
@@ -396,15 +442,7 @@ def _runtime_task_update_message( | |||||||||||||||||||||||||||||
| return f"任务「{task_title}」已取消。" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| body = content or "任务有新的更新,请打开 Wework 查看完整对话。" | ||||||||||||||||||||||||||||||
| return "\n".join( | ||||||||||||||||||||||||||||||
| [ | ||||||||||||||||||||||||||||||
| f"任务「{task_title}」有新的 AI 回复:", | ||||||||||||||||||||||||||||||
| "", | ||||||||||||||||||||||||||||||
| body, | ||||||||||||||||||||||||||||||
| "", | ||||||||||||||||||||||||||||||
| "回复这条通知可继续该任务。", | ||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| return f"任务「{task_title}」有新的 AI 回复:\n\n{body}" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| im_notification_dispatcher = IMNotificationDispatcher() | ||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Do not wait for IM delivery while the runtime-event lock is held.
Line 2217 awaits
_notify_runtime_eventinside the per-socket ordered relay path. Lines 2283-2296 then await IM delivery. A slow dispatcher delays the acknowledgement and blocks later runtime events from the same device socket until delivery returns.Queue this best-effort work through the existing tracked background-task path. Apply a bounded timeout at the delivery boundary.
Also applies to: 2283-2296
🤖 Prompt for AI Agents