From e672f71c1234cc110238a51c3c821794061140e7 Mon Sep 17 00:00:00 2001 From: MuXinCG <202322130196@mail.sdu.edu.cn> Date: Sun, 15 Feb 2026 17:21:29 +0800 Subject: [PATCH] Stable --- EvoScientist/channels/base.py | 46 ++++++++++++++--------- EvoScientist/channels/capabilities.py | 4 +- EvoScientist/channels/channel_manager.py | 5 ++- EvoScientist/channels/consumer.py | 7 +++- EvoScientist/channels/middleware.py | 17 +++++++-- channel_feature_matrix.xlsx | Bin 0 -> 6840 bytes tests/test_channel_comprehensive.py | 8 ++-- 7 files changed, 58 insertions(+), 29 deletions(-) create mode 100644 channel_feature_matrix.xlsx diff --git a/EvoScientist/channels/base.py b/EvoScientist/channels/base.py index 9923cd2..edd475b 100644 --- a/EvoScientist/channels/base.py +++ b/EvoScientist/channels/base.py @@ -176,24 +176,31 @@ async def download_attachment( local_path = media_path(f"{prefix}{safe_name}") async with httpx.AsyncClient(proxy=proxy) as client: - resp = await client.get(url, headers=headers or {}, timeout=30) - if resp.status_code != 200: - return None, f"[attachment: {filename} - download failed]" + async with client.stream("GET", url, headers=headers or {}, timeout=30) as resp: + if resp.status_code != 200: + return None, f"[attachment: {filename} - download failed]" - # Check Content-Length when file_size was not known beforehand - if file_size is None: - cl = resp.headers.get("content-length") - if cl: - try: - too_large = check_attachment_size(int(cl), filename) - if too_large: - return None, too_large - except (ValueError, TypeError): - pass - if len(resp.content) > MAX_ATTACHMENT_BYTES: - return None, check_attachment_size(len(resp.content), filename) + # Check Content-Length header before downloading body + if file_size is None: + cl = resp.headers.get("content-length") + if cl: + try: + too_large = check_attachment_size(int(cl), filename) + if too_large: + return None, too_large + except (ValueError, TypeError): + pass - local_path.write_bytes(resp.content) + # Stream body with incremental size check + chunks: list[bytes] = [] + total = 0 + async for chunk in resp.aiter_bytes(): + total += len(chunk) + if total > MAX_ATTACHMENT_BYTES: + return None, check_attachment_size(total, filename) + chunks.append(chunk) + + local_path.write_bytes(b"".join(chunks)) return str(local_path), f"[attachment: {local_path}]" except Exception as e: _logger.warning(f"Failed to download attachment: {e}") @@ -670,7 +677,12 @@ class Channel(ChannelPlugin, ABC): def _should_process(self, raw: RawIncoming) -> bool: """Decide whether to process a message based on mention gating.""" - if not raw.is_group or self.require_mention == "off": + if self.require_mention == "off": + return True + if self.require_mention == "always": + return raw.was_mentioned + # "group" — require mention only in groups + if not raw.is_group: return True return raw.was_mentioned diff --git a/EvoScientist/channels/capabilities.py b/EvoScientist/channels/capabilities.py index df624e2..b125a22 100644 --- a/EvoScientist/channels/capabilities.py +++ b/EvoScientist/channels/capabilities.py @@ -199,7 +199,7 @@ SIGNAL = ChannelCapabilities( EMAIL = ChannelCapabilities( format_type="html", - max_text_length=0, # no practical limit + max_text_length=999_999, # no practical limit media_send=True, media_receive=True, html=True, @@ -208,7 +208,7 @@ EMAIL = ChannelCapabilities( IMESSAGE = ChannelCapabilities( format_type="plain", - max_text_length=0, + max_text_length=999_999, typing=False, # Apple does not expose typing indicator API media_send=True, media_receive=True, diff --git a/EvoScientist/channels/channel_manager.py b/EvoScientist/channels/channel_manager.py index 577614c..bc2ffb0 100644 --- a/EvoScientist/channels/channel_manager.py +++ b/EvoScientist/channels/channel_manager.py @@ -346,7 +346,10 @@ def build_inbound_pipeline( )) if plugin.pairing is not None: - middlewares.append(PairingMiddleware(channel_name=plugin.id)) + middlewares.append(PairingMiddleware( + channel_name=plugin.id, + dm_policy=dm_policy, + )) if caps.groups: middlewares.append(GroupHistoryMiddleware()) diff --git a/EvoScientist/channels/consumer.py b/EvoScientist/channels/consumer.py index a3ac15c..5fdfd28 100644 --- a/EvoScientist/channels/consumer.py +++ b/EvoScientist/channels/consumer.py @@ -158,7 +158,10 @@ class InboundConsumer: # Evict oldest entry oldest = next(iter(self._sessions)) del self._sessions[oldest] - self._sessions[sender_id] = self.thread_id or str(uuid.uuid4()) + if self.thread_id: + self._sessions[sender_id] = f"{self.thread_id}:{sender_id}" + else: + self._sessions[sender_id] = str(uuid.uuid4()) return self._sessions[sender_id] def _get_channel(self, channel_name: str) -> Channel | None: @@ -361,7 +364,7 @@ class InboundConsumer: await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, - content=f"Error: {e}", + content="Sorry, something went wrong. Please try again later.", metadata=msg.metadata, )) finally: diff --git a/EvoScientist/channels/middleware.py b/EvoScientist/channels/middleware.py index 7bfbf40..9ab67bc 100644 --- a/EvoScientist/channels/middleware.py +++ b/EvoScientist/channels/middleware.py @@ -511,6 +511,12 @@ class FormattingMiddleware(OutboundMiddlewareBase): """Convert text to channel format.""" return self._formatter.format(text) + async def process_outbound( + self, message: OutboundMessage, context: dict[str, Any], + ) -> OutboundMessage | None: + formatted = self._formatter.format(message.content) + return dataclasses.replace(message, content=formatted) + # ── Retry ──────────────────────────────────────────────────────────── @@ -662,11 +668,13 @@ class MentionGatingMiddleware(InboundMiddleware): return raw def _should_process(self, raw: RawIncoming) -> bool: - if not raw.is_group or self.require_mention == "off": + if self.require_mention == "off": return True if self.require_mention == "always": return raw.was_mentioned - # "group" — require mention in groups + # "group" — require mention only in groups + if not raw.is_group: + return True return raw.was_mentioned @@ -779,10 +787,12 @@ class PairingMiddleware(InboundMiddleware): self, channel_name: str, send_response_fn: Callable[[str, str], Any] | None = None, + dm_policy: str = "allowlist", ) -> None: self._manager = PairingManager() self._channel_name = channel_name self._send_response_fn = send_response_fn + self._dm_policy = dm_policy async def process_inbound( self, raw: RawIncoming, context: dict[str, Any], @@ -790,8 +800,7 @@ class PairingMiddleware(InboundMiddleware): if raw.is_group: return raw # pairing only applies to DMs - dm_policy = context.get("dm_policy", "allowlist") - if dm_policy != "pairing": + if self._dm_policy != "pairing": return raw if self._manager.is_approved(self._channel_name, raw.sender_id): diff --git a/channel_feature_matrix.xlsx b/channel_feature_matrix.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..8fa95ad80c02a6672d1e34e6f93a0d96c1e77f1d GIT binary patch literal 6840 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 giS(BpGLnCsRw@cW_>KhtfChhyz|*