diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index 86984913372b..f84ca259ccba 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -47,10 +47,14 @@ def _authorization_adapter( if not platform: return None profile_name = (profile or "").strip() or None - if profile_name: + if profile_name and profile_name != "default": profile_adapters = getattr(self, "_profile_adapters", None) or {} if profile_name in profile_adapters: return profile_adapters[profile_name].get(platform) + # Fail closed: a stamped secondary profile with no registry entry + # (e.g. its adapter failed to connect) must NOT fall back to the + # default profile's adapter — that sends replies out the wrong bot. + return None adapters = getattr(self, "adapters", None) or {} return adapters.get(platform) diff --git a/gateway/run.py b/gateway/run.py index b81e87dba641..51e33ceb4996 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5201,7 +5201,7 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session "Approval response via plain text: session=%s verb=%s args=%r", session_key, _verb, _normalized_args, ) - _adapter = self.adapters.get(event.source.platform) + _adapter = self._adapter_for_source(event.source) if _adapter and _reply: _text, _eph_ttl = _adapter._unwrap_ephemeral(_reply) if _text: @@ -6318,7 +6318,7 @@ async def _drain_startup_restore_queue(self) -> int: while queue: event = queue.pop(0) source = getattr(event, "source", None) - adapter = self.adapters.get(source.platform) if source is not None else None + adapter = self._adapter_for_source(source) if adapter is None: logger.debug( "Dropping startup-restore queued message: adapter unavailable for %s", @@ -6424,7 +6424,7 @@ def _schedule_resume_pending_sessions(self, platform=None) -> int: continue source = entry.origin - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter is None: logger.debug( "Skipping auto-resume for %s: adapter not ready for %s", @@ -8595,7 +8595,7 @@ def check( async def _deliver_platform_notice(self, source, content: str) -> None: """Deliver a setup/operational notice using platform-specific privacy rules.""" - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if not adapter: return @@ -9070,7 +9070,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: has_media = bool(getattr(event, "media_urls", None)) if not queued_text and not has_media: return "Usage: /queue " - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: queued_event = MessageEvent( text=queued_text, @@ -9091,7 +9091,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: timestamp=event.timestamp, ) self._enqueue_fifo(_quick_key, queued_event, adapter) - depth = self._queue_depth(_quick_key, adapter=self.adapters.get(source.platform)) + depth = self._queue_depth(_quick_key, adapter=self._adapter_for_source(source)) if depth <= 1: return "Queued for the next turn." return f"Queued for the next turn. ({depth} queued)" @@ -9108,7 +9108,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: running_agent = self._running_agents.get(_quick_key) if running_agent is _AGENT_PENDING_SENTINEL: # Agent hasn't started yet — queue as turn-boundary fallback. - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: queued_event = MessageEvent( text=steer_text, @@ -9130,7 +9130,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: return f"⏩ Steer queued — arrives after the next tool call: '{preview}'" return "Steer rejected (empty payload)." # Running agent is missing or lacks steer() — fall back to queue. - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: queued_event = MessageEvent( text=steer_text, @@ -9255,7 +9255,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if event.message_type == MessageType.PHOTO: logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key) - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: merge_pending_message_event(adapter._pending_messages, _quick_key, event) return None @@ -9276,7 +9276,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: time.time() - _started_at, _quick_key, ) - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: if self._busy_input_mode == "queue": self._enqueue_fifo(_quick_key, event, adapter) @@ -9299,7 +9299,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: return EphemeralReply("⚡ Force-stopped. The agent was still starting — session unlocked.") # Queue the message so it will be picked up after the # agent starts. - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: merge_pending_message_event( adapter._pending_messages, @@ -9538,7 +9538,7 @@ async def _do_reset(): else "Learning a skill from this conversation…" ) try: - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: _ack_meta = self._thread_metadata_for_source(source) await adapter.send(str(source.chat_id), _ack, metadata=_ack_meta) @@ -9592,7 +9592,7 @@ async def _do_reset(): _ack = getattr(_blueprint_result, "text", "") or "" if _ack: try: - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: _ack_meta = self._thread_metadata_for_source(source) await adapter.send(str(source.chat_id), _ack, metadata=_ack_meta) @@ -10219,7 +10219,7 @@ async def _prepare_inbound_message_text( # while allowing quiet STT for users who only want the agent to # receive the transcription. if _successful_transcripts and self._should_echo_stt_transcripts(): - _echo_adapter = self.adapters.get(source.platform) + _echo_adapter = self._adapter_for_source(source) _echo_meta = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event)) if _echo_adapter: for _tx in _successful_transcripts: @@ -10393,7 +10393,7 @@ async def _prepare_inbound_message_text( allowed_root=_msg_cwd, ) if _ctx_result.blocked: - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if _adapter: await _adapter.send( source.chat_id, @@ -10634,7 +10634,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g and platform_name not in policy.notify_exclude_platforms ) if should_notify: - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: if reset_reason == "suspended": reason_text = "previous session was stopped or interrupted" @@ -11029,7 +11029,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g "configuration." ) try: - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if _adapter and source.chat_id: await _adapter.send(source.chat_id, _warn_msg, metadata=_hyg_meta) except Exception as _werr: @@ -11053,7 +11053,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g "check `auxiliary.compression.model` in config.yaml." ) try: - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if _adapter and source.chat_id: await _adapter.send(source.chat_id, _aux_msg, metadata=_hyg_meta) except Exception as _werr: @@ -11210,7 +11210,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # event so deferred post-delivery callbacks can be released by the # same run that registered them. self._bind_adapter_run_generation( - self.adapters.get(source.platform), + self._adapter_for_source(source), session_key, run_generation, ) @@ -11250,7 +11250,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # Stop persistent typing indicator now that the agent is done try: - _typing_adapter = self.adapters.get(source.platform) + _typing_adapter = self._adapter_for_source(source) if _typing_adapter and hasattr(_typing_adapter, "stop_typing"): await _typing_adapter.stop_typing(source.chat_id) except Exception: @@ -11262,7 +11262,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g _quick_key or "?", run_generation, ) - _stale_adapter = self.adapters.get(source.platform) + _stale_adapter = self._adapter_for_source(source) if getattr(type(_stale_adapter), "pop_post_delivery_callback", None) is not None: _stale_adapter.pop_post_delivery_callback( _quick_key, @@ -11772,7 +11772,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # users see the agent "stop responding without explanation." if agent_result.get("already_sent") and not agent_result.get("failed"): if response: - _media_adapter = self.adapters.get(source.platform) + _media_adapter = self._adapter_for_source(source) if _media_adapter: await self._deliver_media_from_response( response, event, _media_adapter, @@ -11783,7 +11783,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # still surface the runtime metadata on the final reply. if _footer_line: try: - _foot_adapter = self.adapters.get(source.platform) + _foot_adapter = self._adapter_for_source(source) if _foot_adapter: await _foot_adapter.send( source.chat_id, @@ -11799,7 +11799,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g except Exception as e: # Stop typing indicator on error too try: - _err_adapter = self.adapters.get(source.platform) + _err_adapter = self._adapter_for_source(source) if _err_adapter and hasattr(_err_adapter, "stop_typing"): await _err_adapter.stop_typing(source.chat_id) except Exception: @@ -12304,7 +12304,7 @@ def _get_goal_manager_for_event(self, event: "MessageEvent"): async def _send_goal_status_notice(self, source: Any, message: str) -> None: """Send a /goal judge status line back to the originating chat/thread.""" - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if not adapter: logger.debug("goal continuation: no adapter for %s", getattr(source, "platform", None)) return @@ -12331,7 +12331,7 @@ async def _defer_goal_status_notice_after_delivery(self, source: Any, message: s exactly this boundary; when unavailable, fall back to direct awaited delivery rather than silently dropping the notice. """ - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if not adapter: logger.debug("goal continuation: no adapter for %s", getattr(source, "platform", None)) return @@ -12429,7 +12429,7 @@ async def _post_turn_goal_continuation( # Enqueue via the adapter's FIFO so a user message already in # flight preempts the continuation naturally. try: - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) _quick_key = self._session_key_for_source(source) if adapter and _quick_key: cont_event = MessageEvent( @@ -12462,7 +12462,7 @@ def _get_guild_id(event: MessageEvent) -> Optional[int]: async def _handle_voice_channel_join(self, event: MessageEvent) -> str: """Join the user's current Discord voice channel.""" - adapter = self.adapters.get(event.source.platform) + adapter = self._adapter_for_source(event.source) if not hasattr(adapter, "join_voice_channel"): return "Voice channels are not supported on this platform." @@ -12519,7 +12519,7 @@ async def _handle_voice_channel_join(self, event: MessageEvent) -> str: async def _handle_voice_channel_leave(self, event: MessageEvent) -> str: """Leave the Discord voice channel.""" - adapter = self.adapters.get(event.source.platform) + adapter = self._adapter_for_source(event.source) guild_id = self._get_guild_id(event) if not guild_id or not hasattr(adapter, "leave_voice_channel"): @@ -12753,7 +12753,7 @@ async def _send_voice_reply(self, event: MessageEvent, text: str) -> None: logger.warning("Auto voice reply TTS failed: %s", result.get("error")) return - adapter = self.adapters.get(event.source.platform) + adapter = self._adapter_for_source(event.source) # If connected to a voice channel, play there instead of sending a file guild_id = self._get_guild_id(event) @@ -12931,7 +12931,7 @@ async def _run_background_task( media_urls = media_urls or [] media_types = media_types or [] - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if not adapter: logger.warning("No adapter for platform %s in background task %s", source.platform, task_id) return @@ -13126,7 +13126,7 @@ def run_sync(): async def _get_telegram_topic_capabilities(self, source: SessionSource) -> dict: """Read Telegram private-topic capability flags via Bot API getMe.""" - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + adapter = self._adapter_for_source(source) bot = getattr(adapter, "_bot", None) if bot is None or not hasattr(bot, "get_me"): return {"checked": False} @@ -13154,7 +13154,7 @@ def _field(name: str): async def _ensure_telegram_system_topic(self, source: SessionSource) -> None: """Create/pin the managed System topic after /topic activation when possible.""" - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + adapter = self._adapter_for_source(source) if adapter is None or not source.chat_id: return @@ -13195,7 +13195,7 @@ async def _ensure_telegram_system_topic(self, source: SessionSource) -> None: async def _send_telegram_topic_setup_image(self, source: SessionSource) -> None: """Send the bundled BotFather Threads Settings screenshot when available.""" - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + adapter = self._adapter_for_source(source) if adapter is None or not source.chat_id or not hasattr(adapter, "send_image_file"): return image_path = Path(__file__).resolve().parent / "assets" / "telegram-botfather-threads-settings.jpg" @@ -13247,7 +13247,7 @@ async def _rename_telegram_topic_for_session_title( # Check the class, not the instance — getattr() on MagicMock # auto-creates attributes, so `hasattr(adapter, "_get_dm_topic_info")` # would return True for every test double. - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + adapter = self._adapter_for_source(source) if adapter is not None: get_info = getattr(type(adapter), "_get_dm_topic_info", None) if callable(get_info): @@ -13806,7 +13806,7 @@ async def _request_slash_confirm( # cannot race the send_slash_confirm return. _slash_confirm_mod.register(session_key, confirm_id, command, handler) - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) metadata = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event)) used_buttons = False @@ -14820,7 +14820,7 @@ async def _dequeue_pending_with_transcription( # Echo raw transcripts back to the user when configured so voice # interrupts feel identical to fresh voice messages. if successful_transcripts and self._should_echo_stt_transcripts(): - echo_adapter = self.adapters.get(source.platform) + echo_adapter = self._adapter_for_source(source) echo_meta = {"thread_id": source.thread_id} if source.thread_id else None if echo_adapter: for tx in successful_transcripts: @@ -15642,7 +15642,7 @@ async def _interrupt_and_clear_session( if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: running_agent.interrupt(interrupt_reason) self._invalidate_session_run_generation(session_key, reason=invalidation_reason) - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter and hasattr(adapter, "interrupt_session_activity"): await adapter.interrupt_session_activity(session_key, source.chat_id) if adapter and hasattr(adapter, "get_pending_message"): @@ -16207,7 +16207,7 @@ def _run_still_current() -> bool: if _streaming_enabled: try: from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if _adapter: _pause_typing_before_finalize = None if source.platform == Platform.TELEGRAM and hasattr(_adapter, "pause_typing_for_chat"): @@ -16258,7 +16258,7 @@ def _pause_typing_before_finalize( stream_task = asyncio.create_task(_stream_consumer.run()) # Send typing indicator - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if _adapter: try: await _adapter.send_typing(source.chat_id, metadata=_thread_metadata) @@ -16698,7 +16698,7 @@ def voice_ack_callback(call_id, tool_name, args): _cleanup_progress = bool( resolve_display_setting(user_config, platform_key, "cleanup_progress") ) - _cleanup_adapter = self.adapters.get(source.platform) if _cleanup_progress else None + _cleanup_adapter = self._adapter_for_source(source) if _cleanup_progress else None if _cleanup_adapter is not None and ( type(_cleanup_adapter).delete_message is BasePlatformAdapter.delete_message ): @@ -16819,7 +16819,7 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non _code_block_full = None _code_block_short = None try: - _progress_adapter = self.adapters.get(source.platform) + _progress_adapter = self._adapter_for_source(source) except Exception: _progress_adapter = None if ( @@ -17009,7 +17009,7 @@ async def send_progress_messages(): if not progress_queue: return - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if not adapter: return @@ -17384,7 +17384,7 @@ def _event_callback_sync(event_type: str, context: dict) -> None: logger.debug("event_callback hook error: %s", _e) # Bridge sync status_callback → async adapter.send for context pressure - _status_adapter = self.adapters.get(source.platform) + _status_adapter = self._adapter_for_source(source) _status_chat_id = source.chat_id if source.platform == Platform.FEISHU and source.thread_id and event_message_id: # Feishu topics only keep messages inside the topic when they are @@ -17530,7 +17530,7 @@ def run_sync(): if _want_stream_deltas or _want_interim_consumer: try: from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if _adapter: _pause_typing_before_finalize = None if source.platform == Platform.TELEGRAM and hasattr(_adapter, "pause_typing_for_chat"): @@ -18698,7 +18698,7 @@ async def monitor_for_interrupt(): try: # Re-resolve adapter each iteration so reconnects don't # leave us holding a stale reference. - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if not _adapter: continue # Check if adapter has a pending interrupt for this session. @@ -18793,7 +18793,7 @@ async def monitor_for_interrupt(): async def _notify_long_running(): if _NOTIFY_INTERVAL is None: return # Notifications disabled (gateway_notify_interval: 0) - _notify_adapter = self.adapters.get(source.platform) + _notify_adapter = self._adapter_for_source(source) if not _notify_adapter: return # Track the heartbeat message id so we can edit-in-place on @@ -18938,7 +18938,7 @@ def _stream_confirmed_final_delivery( # Backup interrupt check: if the monitor task died or # missed the interrupt, catch it here. if not _interrupt_detected.is_set() and session_key: - _backup_adapter = self.adapters.get(source.platform) + _backup_adapter = self._adapter_for_source(source) _backup_agent = agent_holder[0] if (_backup_adapter and _backup_agent and hasattr(_backup_adapter, 'has_pending_interrupt') @@ -18978,7 +18978,7 @@ def _stream_confirmed_final_delivery( if (not _warning_fired and _agent_warning is not None and _idle_secs >= _agent_warning): _warning_fired = True - _warn_adapter = self.adapters.get(source.platform) + _warn_adapter = self._adapter_for_source(source) if _warn_adapter: _elapsed_warn = int(_agent_warning // 60) or 1 _remaining_mins = int((_agent_timeout - _agent_warning) // 60) or 1 @@ -18998,7 +18998,7 @@ def _stream_confirmed_final_delivery( break # Backup interrupt check (same as unlimited path). if not _interrupt_detected.is_set() and session_key: - _backup_adapter = self.adapters.get(source.platform) + _backup_adapter = self._adapter_for_source(source) _backup_agent = agent_holder[0] if (_backup_adapter and _backup_agent and hasattr(_backup_adapter, 'has_pending_interrupt') @@ -19115,7 +19115,7 @@ def _stream_confirmed_final_delivery( # Check if we were interrupted OR have a queued message (/queue). result = result_holder[0] - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) # Get pending message from adapter. # Use session_key (not source.chat_id) to match adapter's storage keys. @@ -19246,7 +19246,7 @@ def _stream_confirmed_final_delivery( "queueing message instead of recursing.", _interrupt_depth, session_key, ) - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter and pending_event: merge_pending_message_event(adapter._pending_messages, session_key, pending_event) elif adapter and hasattr(adapter, 'queue_message'): @@ -19365,7 +19365,7 @@ def _stream_confirmed_final_delivery( # Restart typing indicator so the user sees activity while # the follow-up turn runs. The outer _process_message_background # typing task is still alive but may be stale. - _followup_adapter = self.adapters.get(source.platform) + _followup_adapter = self._adapter_for_source(source) if _followup_adapter: try: await _followup_adapter.send_typing( diff --git a/scripts/release.py b/scripts/release.py index 39ef393836a8..dca7e8d8780e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -46,6 +46,8 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "derek2000139@qq.com": "derek2000139", # PR #57838 salvage (desktop/windows: pre-write update marker before quit dwell so the renderer's waitForUpdateToFinish gate parks instead of respawning a backend that re-locks venv .pyd files mid-update) + "AndreasHiltner@users.noreply.github.com": "AndreasHiltner", # PR #56854 salvage (gateway: route multiplex profile responses through the profile's own adapter — 53-site _adapter_for_source sweep) + "m888.braun@hotmail.com": "ManniBr", # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles) "jashlee+microsoft@microsoft.com": "s905060", # PR #57943 salvage (photon: auto-reinstall stale sidecar node_modules when lockfile is newer than npm's install marker; #59169) "lohinth25@proton.me": "l0h1nth", # PR #32210 salvage (mattermost: accept leading-space slash commands from mobile clients; #25184) "perkintahmaz50@gmail.com": "devatnull", # PR #58704 salvage (whatsapp: native Baileys polls, clarify-as-poll, location pins, structured quoted replies, PTT/audio split, bridge_helpers extraction) diff --git a/tests/gateway/test_notice_rendering.py b/tests/gateway/test_notice_rendering.py index 89a5435a507a..3ba731f32e41 100644 --- a/tests/gateway/test_notice_rendering.py +++ b/tests/gateway/test_notice_rendering.py @@ -107,6 +107,10 @@ def _make_source(platform_value="telegram", chat_id="555", user_id="u1"): src.platform = plat src.chat_id = chat_id src.user_id = user_id + # Real SessionSource.profile is None (single-profile) or a str; a MagicMock + # auto-attribute would read as a truthy "stamped profile" and trip the + # fail-closed path in _adapter_for_source (see AGENTS.md pitfall #17). + src.profile = None return src diff --git a/tests/gateway/test_queue_consumption.py b/tests/gateway/test_queue_consumption.py index ec1b0dedc83d..857da98da71c 100644 --- a/tests/gateway/test_queue_consumption.py +++ b/tests/gateway/test_queue_consumption.py @@ -382,7 +382,9 @@ def _make_runner_and_adapter(self): return runner, adapter def _text_event(self, text: str) -> MessageEvent: - source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM) + # profile=None: a MagicMock auto-attribute reads as a truthy stamped + # profile and trips fail-closed adapter resolution (AGENTS.md #17). + source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM, profile=None) return MessageEvent( text=text, message_type=MessageType.TEXT, @@ -433,7 +435,7 @@ def test_photo_burst_still_merges_in_head_slot(self): runner, adapter = self._make_runner_and_adapter() session_key = "telegram:user:burst" - source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM) + source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM, profile=None) for i in range(3): runner._queue_or_replace_pending_event( session_key,