From c299d5a33d9a213921154da419c4f6a068630bd8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:54:38 -0700 Subject: [PATCH 01/12] =?UTF-8?q?refactor(gateway/platforms):=20signal.py?= =?UTF-8?q?=20second=20pass=20=E2=80=94=20collapse=20guards,=20table-drive?= =?UTF-8?q?n=20skip=20logging,=20fold=20header=20layouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/signal.py | 189 ++++++++++++++---------------------- 1 file changed, 74 insertions(+), 115 deletions(-) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index d5cb812150..af0864eef7 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -1,9 +1,6 @@ -"""Signal messenger platform adapter. - -Connects to a signal-cli daemon in HTTP mode (``signal-cli daemon --http 127.0.0.1:8080``): -inbound messages arrive via SSE, outbound messages/actions use JSON-RPC 2.0 over HTTP. -Requires SIGNAL_HTTP_URL and SIGNAL_ACCOUNT. -""" +"""Signal messenger platform adapter for a signal-cli daemon in HTTP mode (``signal-cli daemon +--http 127.0.0.1:8080``): inbound via SSE, outbound via JSON-RPC 2.0. Requires SIGNAL_HTTP_URL +and SIGNAL_ACCOUNT.""" import asyncio import base64 @@ -55,6 +52,11 @@ _MAGIC_EXTENSIONS = ((b"\x89PNG", ".png"), (b"\xff\xd8", ".jpg"), (b"GIF8", ".gi _MEDIA_TYPE_BY_MIME_PREFIX = ( ("audio/", MessageType.VOICE), ("image/", MessageType.PHOTO), ("video/", MessageType.VIDEO)) _OUTCOME_REACTION = {ProcessingOutcome.SUCCESS: "✅", ProcessingOutcome.FAILURE: "❌"} +# send_multiple_images skip reasons → logger.warning args (url, detail). +_SKIP_IMAGE_LOG = { + "download": lambda url, detail: ("Signal: failed to download image %s: %s", url, detail), + "missing": lambda url, detail: ("Signal: image file not found for %s", url), + "oversize": lambda url, detail: ("Signal: image too large (%d bytes), skipping %s", detail, url)} _QUOTE_AUTHOR_KEYS = ( "author", "authorNumber", "authorUuid", "authorAci", "authorServiceId", "authorServiceIdString") @@ -141,24 +143,20 @@ def _render_mentions(text: str, mentions: list) -> str: def _is_signal_service_id(value: str) -> bool: - """Return True if *value* already looks like a Signal service identifier.""" + """Return True if *value* already looks like a Signal service identifier (PNI:/u: prefix or UUID).""" if not value: return False if value.startswith(("PNI:", "u:")): return True - try: + with suppress(ValueError, AttributeError, TypeError): uuid.UUID(value) return True - except (ValueError, AttributeError, TypeError): - return False + return False def _looks_like_e164_number(value: str) -> bool: """Return True for a plausible E.164 phone number.""" - if not value or not value.startswith("+"): - return False - digits = value[1:] - return digits.isdigit() and 7 <= len(digits) <= 15 + return bool(value) and value.startswith("+") and value[1:].isdigit() and 7 <= len(value) - 1 <= 15 def check_signal_requirements() -> bool: @@ -189,15 +187,13 @@ class SignalAdapter(BasePlatformAdapter): self.http_url = extra.get("http_url", "http://127.0.0.1:8080").rstrip("/") self.account = extra.get("account", "") self.ignore_stories = extra.get("ignore_stories", True) - # Allowlists are per-profile (scoped reads); group policy derives from the group - # allowlist's presence. Mention filter: only respond in groups when @mentioned. + # Allowlists are per-profile (scoped reads); group policy derives from the group allowlist's + # presence. The DM allowlist mirrors run.py's SIGNAL_ALLOWED_USERS so reaction hooks (which + # fire before run.py's auth gate) can skip unauthorized senders; "*" = open. self.group_allow_from = set(_parse_comma_list(_sig_secret("SIGNAL_GROUP_ALLOWED_USERS", ""))) _rm_cfg = extra.get("require_mention") - self.require_mention = ( - bool(_rm_cfg) if _rm_cfg is not None - else os.getenv("SIGNAL_REQUIRE_MENTION", "false").lower() in TRUTHY_STRINGS) - # DM allowlist mirrors run.py's SIGNAL_ALLOWED_USERS so reaction hooks (which fire - # before run.py's auth gate) can skip unauthorized senders. "*" = open. + self.require_mention = (bool(_rm_cfg) if _rm_cfg is not None + else os.getenv("SIGNAL_REQUIRE_MENTION", "false").lower() in TRUTHY_STRINGS) self.dm_allow_from = set(_parse_comma_list(_sig_secret("SIGNAL_ALLOWED_USERS", "*"))) self.client: Optional[httpx.AsyncClient] = None self._sse_task: Optional[asyncio.Task] = None @@ -210,22 +206,21 @@ class SignalAdapter(BasePlatformAdapter): self._last_sse_activity = 0.0 self._sse_response: Optional[httpx.Response] = None self._account_normalized = self.account.strip() - # Recently sent timestamps filter echo-backs (Note to Self / linked-device sync-sents). - # LRU + TTL so a pending echo in a chatty group isn't evicted by many outbounds. + # Recently sent timestamps filter echo-backs (Note to Self / linked-device sync-sents); LRU + TTL + # so a pending echo in a chatty group isn't evicted by many outbounds. self._recent_sent_timestamps: "OrderedDict[int, float]" = OrderedDict() self._max_recent_timestamps = 512 self._recent_sent_ttl_seconds = 300.0 - # Separate FIFO of outbound timestamps: Signal quote.id is the quoted message's - # timestamp, so replies to this bot are recognised after the echo was consumed. + # Separate FIFO of outbound timestamps: Signal quote.id is the quoted message's timestamp, so + # replies to this bot are recognised after the echo was consumed. self._sent_message_timestamps: "OrderedDict[str, None]" = OrderedDict() self._max_sent_message_timestamps = 500 # Best-effort number↔ACI/PNI UUID mapping so sends can upgrade a number to the UUID signal-cli prefers. self._recipient_uuid_by_number: Dict[str, str] = {} self._recipient_number_by_uuid: Dict[str, str] = {} self._recipient_cache_lock = asyncio.Lock() - logger.info("Signal adapter initialized: url=%s account=%s groups=%s", - self.http_url, redact_phone(self.account), - "enabled" if self.group_allow_from else "disabled") + logger.info("Signal adapter initialized: url=%s account=%s groups=%s", self.http_url, + redact_phone(self.account), "enabled" if self.group_allow_from else "disabled") async def connect(self, *, is_reconnect: bool = False) -> bool: """Connect to signal-cli daemon and start SSE listener.""" @@ -246,12 +241,12 @@ class SignalAdapter(BasePlatformAdapter): try: try: resp = await self.client.get(f"{self.http_url}/api/v1/check", timeout=10.0) - if resp.status_code != 200: - logger.error("Signal: health check failed (status %d)", resp.status_code) - return False except Exception as e: logger.error("Signal: cannot reach signal-cli at %s: %s", self.http_url, e) return False + if resp.status_code != 200: + logger.error("Signal: health check failed (status %d)", resp.status_code) + return False self._running = True self._last_sse_activity = time.time() self._sse_task = asyncio.create_task(self._sse_listener()) @@ -266,11 +261,6 @@ class SignalAdapter(BasePlatformAdapter): if lock_acquired: self._release_platform_lock() - def _spawn_background(self, coro) -> None: - task = asyncio.create_task(coro) - self._background_tasks.add(task) - task.add_done_callback(self._background_tasks.discard) - async def _close_client(self) -> None: if self.client: await self.client.aclose() @@ -375,7 +365,9 @@ class SignalAdapter(BasePlatformAdapter): """Force SSE reconnection by closing the current response.""" if self._sse_response and not self._sse_response.is_stream_consumed: with suppress(Exception): - self._spawn_background(self._sse_response.aclose()) + task = asyncio.create_task(self._sse_response.aclose()) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) self._sse_response = None def _unwrap_sync_message(self, envelope_data: dict) -> Optional[dict]: @@ -386,8 +378,7 @@ class SignalAdapter(BasePlatformAdapter): if not sent_msg or not isinstance(sent_msg, dict): return None dest = sent_msg.get("destinationNumber") or sent_msg.get("destination") - sent_msg_group_id = (sent_msg.get("groupInfo") or {}).get("groupId") - if dest != self._account_normalized and not sent_msg_group_id: + if dest != self._account_normalized and not (sent_msg.get("groupInfo") or {}).get("groupId"): return None if self._consume_sent_timestamp(sent_msg.get("timestamp")): return None # echo of our own outbound reply @@ -442,9 +433,8 @@ class SignalAdapter(BasePlatformAdapter): envelope_data = self._unwrap_sync_message(envelope_data) if envelope_data is None: return - sender = ( - envelope_data.get("sourceNumber") or envelope_data.get("sourceUuid") or envelope_data.get("source") - ) + sender = (envelope_data.get("sourceNumber") or envelope_data.get("sourceUuid") + or envelope_data.get("source")) sender_name = envelope_data.get("sourceName", "") sender_uuid = envelope_data.get("sourceUuid", "") self._remember_recipient_identifiers(sender, sender_uuid) @@ -457,9 +447,8 @@ class SignalAdapter(BasePlatformAdapter): if self.ignore_stories and envelope_data.get("storyMessage"): return # Edited messages carry their updated dataMessage inside editMessage - data_message = ( - envelope_data.get("dataMessage") or (envelope_data.get("editMessage") or {}).get("dataMessage") - ) + data_message = (envelope_data.get("dataMessage") + or (envelope_data.get("editMessage") or {}).get("dataMessage")) if not data_message: return group_info = data_message.get("groupInfo") @@ -550,14 +539,10 @@ class SignalAdapter(BasePlatformAdapter): return True if not reply_to_author: return False - author = str(reply_to_author).strip() - if self._account_normalized and author == self._account_normalized: - return True - cached_uuid = self._recipient_uuid_by_number.get(self._account_normalized) - if cached_uuid and author == cached_uuid: - return True - cached_number = self._recipient_number_by_uuid.get(author) - return bool(cached_number and cached_number == self._account_normalized) + author, acct = str(reply_to_author).strip(), self._account_normalized + # Cached number↔UUID mappings are only ever stored with truthy keys and values. + return bool(acct) and (author == acct or author == self._recipient_uuid_by_number.get(acct) + or self._recipient_number_by_uuid.get(author) == acct) def _remember_sent_message_timestamp(self, timestamp: Any) -> None: """Keep a bounded cache of outbound Signal timestamps for quote matching.""" @@ -578,8 +563,8 @@ class SignalAdapter(BasePlatformAdapter): profile = contact.get("profile") if not service_id and isinstance(profile, dict): service_id = profile.get("serviceId") or profile.get("uuid") - if service_id and _is_signal_service_id(service_id) and ( - contact.get("number") == phone_number or contact.get("recipient") == phone_number): + if service_id and _is_signal_service_id(service_id) and phone_number in ( + contact.get("number"), contact.get("recipient")): return service_id return None @@ -595,12 +580,11 @@ class SignalAdapter(BasePlatformAdapter): if cached: return cached contacts = await self._rpc("listContacts", {"account": self.account, "allRecipients": True}) - if isinstance(contacts, list): - for contact in contacts: - number = contact.get("number") if isinstance(contact, dict) else None - service_id = self._extract_contact_uuid(contact, chat_id) - if number and service_id: - self._remember_recipient_identifiers(number, service_id) + for contact in contacts if isinstance(contacts, list) else (): + number = contact.get("number") if isinstance(contact, dict) else None + service_id = self._extract_contact_uuid(contact, chat_id) + if number and service_id: + self._remember_recipient_identifiers(number, service_id) return self._recipient_uuid_by_number.get(chat_id, chat_id) async def _with_target(self, params: Dict[str, Any], chat_id: str, *, resolve: bool = True) -> Dict[str, Any]: @@ -616,8 +600,7 @@ class SignalAdapter(BasePlatformAdapter): result = await self._rpc("getAttachment", {"account": self.account, "id": attachment_id}) if not result: return None, "" - # signal-cli returns {"data": "base64..."} - if isinstance(result, dict): + if isinstance(result, dict): # signal-cli returns {"data": "base64..."} result = result.get("data") if not result: logger.warning("Signal: attachment response missing 'data' key") @@ -669,10 +652,8 @@ class SignalAdapter(BasePlatformAdapter): logger.log(fail_level, "Signal RPC %s failed: %s", method, e) return None - @staticmethod - def _markdown_to_signal(text: str) -> tuple[str, list[str]]: - """Backward-compatible wrapper around shared Signal formatting helper.""" - return markdown_to_signal(text) + # Backward-compatible alias for the shared formatting helper. + _markdown_to_signal = staticmethod(markdown_to_signal) def format_message(self, content: str) -> str: """Plain-text fallback for the base-class send path; send() applies rich styles itself.""" @@ -680,9 +661,7 @@ class SignalAdapter(BasePlatformAdapter): def _validate_send_result(self, result: Any) -> tuple[bool, Optional[str]]: """Validate signal-cli send response results. Returns (success, error_message).""" - if not result or not isinstance(result, dict): - return True, None - results = result.get("results") + results = result.get("results") if isinstance(result, dict) else None for r in results if isinstance(results, list) else (): if not isinstance(r, dict): continue @@ -709,26 +688,22 @@ class SignalAdapter(BasePlatformAdapter): except (TypeError, ValueError): logger.debug("[Signal] Ignoring malformed textStyle range: %r", style_string) continue - overlap_start = max(style_start, chunk_start) - overlap_end = min(style_end, chunk_end) + overlap_start, overlap_end = max(style_start, chunk_start), min(style_end, chunk_end) if overlap_start < overlap_end: adjusted.append(f"{overlap_start - chunk_start}:{overlap_end - overlap_start}:{style_type}") return adjusted @classmethod def _split_signal_formatted_message( - cls, plain_text: str, text_styles: list[str], max_length: int, - ) -> list[tuple[str, list[str]]]: + cls, plain_text: str, text_styles: list[str], max_length: int) -> list[tuple[str, list[str]]]: """Split converted Signal text into chunks, translating body ranges per chunk. Splitting after conversion keeps styles that cross a chunk boundary intact instead of leaking Markdown markers.""" if utf16_len(plain_text) <= max_length: return [(plain_text, text_styles)] - indicator_reserve = 10 # Mirrors BasePlatformAdapter.truncate_message(). - body_limit = max(1, max_length - indicator_reserve) + body_limit = max(1, max_length - 10) # 10 = indicator reserve, mirrors truncate_message(). offsets = cls._utf16_offsets(plain_text) chunks: list[tuple[str, list[str]]] = [] - start_idx = 0 - total_u16 = offsets[-1] + start_idx, total_u16 = 0, offsets[-1] while offsets[start_idx] < total_u16: end_budget = min(total_u16, offsets[start_idx] + body_limit) end_idx = start_idx + 1 @@ -753,9 +728,8 @@ class SignalAdapter(BasePlatformAdapter): self._track_sent_timestamp(result) return result, None - async def send( - self, chat_id: str, content: str, reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None) -> SendResult: + async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None) -> SendResult: """Send a text message with native Signal formatting.""" await self._stop_typing_indicator(chat_id) if not content or not content.strip(): @@ -770,8 +744,8 @@ class SignalAdapter(BasePlatformAdapter): params["textStyle"] = text_styles[0] elif text_styles: params["textStyles"] = text_styles - logger.info("[Signal] Sending response chunk %d/%d (%d chars) to %s", - idx, len(chunks), len(plain_text), chat_id) + logger.info("[Signal] Sending response chunk %d/%d (%d chars) to %s", idx, len(chunks), len(plain_text), + chat_id) last_result, err = await self._rpc_send(params, "RPC send failed") if err: return err @@ -813,8 +787,7 @@ class SignalAdapter(BasePlatformAdapter): self._typing_skip_until.pop(chat_id, None) return fails = self._typing_failures[chat_id] = fails + 1 - # After 3 consecutive failures back off exponentially (16s, 32s, 60s cap). - if fails >= 3: + if fails >= 3: # exponential backoff: 16s, 32s, 60s cap self._typing_skip_until[chat_id] = now + min(60.0, 16.0 * (2 ** (fails - 3))) async def _resolve_image_path(self, image_url: str) -> Tuple[Optional[str], Optional[str], Any]: @@ -852,18 +825,12 @@ class SignalAdapter(BasePlatformAdapter): attachments.append(file_path) continue skipped[reason] += 1 - if reason == "download": - logger.warning("Signal: failed to download image %s: %s", image_url, detail) - elif reason == "missing": - logger.warning("Signal: image file not found for %s", image_url) - else: - logger.warning("Signal: image too large (%d bytes), skipping %s", detail, image_url) + logger.warning(*_SKIP_IMAGE_LOG[reason](image_url, detail)) if not attachments: logger.error("Signal: no valid images in batch of %d (download=%d missing=%d oversize=%d)", len(images), skipped["download"], skipped["missing"], skipped["oversize"]) return - logger.info("Signal send_multiple_images: %d/%d images valid, sending in chunks", - len(attachments), len(images)) + logger.info("Signal send_multiple_images: %d/%d images valid, sending in chunks", len(attachments), len(images)) base_params = await self._with_target({"account": self.account, "message": ""}, chat_id) per = SIGNAL_MAX_ATTACHMENTS_PER_MSG att_batches = [attachments[i:i + per] for i in range(0, len(attachments), per)] @@ -880,8 +847,7 @@ class SignalAdapter(BasePlatformAdapter): async def _send_attachment_batch(self, scheduler, params: Dict[str, Any], n: int, label: str) -> None: """Send one attachment batch with rate-limit pacing and a single transient retry. Tokens are deducted only on validated success (None = server never accepted it); 429s feed the scheduler.""" - send_timeout = _signal_send_timeout(n) - max_attempts = SIGNAL_RATE_LIMIT_MAX_ATTEMPTS + send_timeout, max_attempts = _signal_send_timeout(n), SIGNAL_RATE_LIMIT_MAX_ATTEMPTS for attempt in range(1, max_attempts + 1): await scheduler.acquire(n) t0 = time.monotonic() @@ -928,9 +894,9 @@ class SignalAdapter(BasePlatformAdapter): if reason == "download": logger.warning("Signal: failed to download image: %s", detail) if reason: - error = {"download": str(detail), "missing": "Image file not found", - "oversize": f"Image too large ({detail} bytes)"}[reason] - return SendResult(success=False, error=error) + return SendResult(success=False, error={ + "download": str(detail), "missing": "Image file not found", + "oversize": f"Image too large ({detail} bytes)"}[reason]) return await self._send_file(chat_id, file_path, caption, "RPC send with attachment failed") async def _send_file(self, chat_id: str, file_path: str, caption: Optional[str], fail_error: str) -> SendResult: @@ -940,9 +906,8 @@ class SignalAdapter(BasePlatformAdapter): _, err = await self._rpc_send(params, fail_error) return err or SendResult(success=True) - async def _send_attachment( - self, chat_id: str, file_path: str, media_label: str, caption: Optional[str] = None, - ) -> SendResult: + async def _send_attachment(self, chat_id: str, file_path: str, media_label: str, + caption: Optional[str] = None) -> SendResult: """Send any local file as a Signal attachment (shared by send_document/image_file/voice/video).""" await self._stop_typing_indicator(chat_id) try: @@ -991,8 +956,7 @@ class SignalAdapter(BasePlatformAdapter): async def send_reaction(self, chat_id: str, emoji: str, target_author: str, target_timestamp: int) -> bool: """React to the message (author number/UUID, Signal ms timestamp) via signal-cli RPC.""" ok = await self._send_reaction_rpc(chat_id, { - "account": self.account, "emoji": emoji, "targetAuthor": target_author, - "targetTimestamp": target_timestamp}) + "account": self.account, "emoji": emoji, "targetAuthor": target_author, "targetTimestamp": target_timestamp}) if not ok: logger.debug("Signal: sendReaction failed (chat=%s, emoji=%s)", chat_id[:20], emoji) return ok @@ -1000,8 +964,8 @@ class SignalAdapter(BasePlatformAdapter): async def remove_reaction(self, chat_id: str, target_author: str, target_timestamp: int) -> bool: """Remove a reaction by sending an empty-string emoji.""" return await self._send_reaction_rpc(chat_id, { - "account": self.account, "emoji": "", "targetAuthor": target_author, - "targetTimestamp": target_timestamp, "remove": True}) + "account": self.account, "emoji": "", "targetAuthor": target_author, "targetTimestamp": target_timestamp, + "remove": True}) def _extract_reaction_target(self, event: MessageEvent) -> Optional[tuple]: """Extract (target_author, target_timestamp) from a MessageEvent, or None.""" @@ -1015,11 +979,8 @@ class SignalAdapter(BasePlatformAdapter): so an unauthorized contact's 👀 would otherwise reveal a listening bot.""" if os.getenv("SIGNAL_REACTIONS", "true").lower() in {"false", "0", "no"}: return False - if event is not None: - sender = getattr(getattr(event, "source", None), "user_id", None) - if sender and "*" not in self.dm_allow_from and sender not in self.dm_allow_from: - return False - return True + sender = getattr(getattr(event, "source", None), "user_id", None) if event is not None else None + return not (sender and "*" not in self.dm_allow_from and sender not in self.dm_allow_from) async def on_processing_start(self, event: MessageEvent) -> None: """React with 👀 when processing begins.""" @@ -1036,11 +997,9 @@ class SignalAdapter(BasePlatformAdapter): target = self._extract_reaction_target(event) if not target: return - chat_id = event.source.chat_id - await self.remove_reaction(chat_id, *target) - emoji = _OUTCOME_REACTION.get(outcome) - if emoji: - await self.send_reaction(chat_id, emoji, *target) + await self.remove_reaction(event.source.chat_id, *target) + if emoji := _OUTCOME_REACTION.get(outcome): + await self.send_reaction(event.source.chat_id, emoji, *target) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Get information about a chat/contact.""" From af1e13f13ed92e8938d41ba0663562a84e054b60 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:08:30 -0700 Subject: [PATCH 02/12] =?UTF-8?q?refactor(gateway/platforms):=20webhook.py?= =?UTF-8?q?=20second=20pass=20=E2=80=94=20unify=20replay-window=20check,?= =?UTF-8?q?=20compact=20comments/layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/webhook.py | 281 ++++++++++++++--------------------- 1 file changed, 114 insertions(+), 167 deletions(-) diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 6dd28fa3a3..3635b5340f 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -1,10 +1,10 @@ -"""Generic webhook platform adapter: aiohttp server that validates HMAC-signed POSTs (GitHub, -GitLab, Svix, Linear, generic), renders payloads into agent prompts, and routes responses back -(github_comment or any gateway platform). Routes live under platforms.webhook.extra.routes: -events (header filter), secret (REQUIRED; "INSECURE_NO_AUTH" skips validation, loopback only), -prompt template, skills, deliver/deliver_extra, deliver_only (rendered prompt IS the message). -Per-route rate limiting, idempotency cache, body-size caps checked before reading. Generic HMAC -V2 binds a timestamp for replay protection; body-only V1 is deprecated but accepted with a warning.""" +"""Generic webhook platform adapter: aiohttp server that validates HMAC-signed POSTs (GitHub, GitLab, +Svix, Linear, generic), renders payloads into agent prompts, and routes responses back (github_comment +or any gateway platform). Routes live under platforms.webhook.extra.routes: events (header filter), +secret (REQUIRED; "INSECURE_NO_AUTH" skips validation, loopback only), prompt template, skills, +deliver/deliver_extra, deliver_only (rendered prompt IS the message). Per-route rate limiting, +idempotency cache, body-size caps checked before reading. Generic HMAC V2 binds a timestamp for +replay protection; body-only V1 is deprecated but accepted with a warning.""" import asyncio import base64 @@ -36,8 +36,8 @@ from gateway.response_filters import is_autonomous_silence_response logger = logging.getLogger(__name__) -# Sentinel from _resolve_request_profile: a /p// prefix names a profile -# this gateway does not serve (→ 404). Distinct from None (no prefix / default). +# _resolve_request_profile sentinel: /p// names a profile this gateway does not serve (→ 404); +# distinct from None (no prefix / default). _PROFILE_REJECTED = object() _UNPARSEABLE = object() @@ -46,10 +46,9 @@ _BUILTIN_DELIVER_PLATFORMS = { "homeassistant", "email", "dingtalk", "feishu", "wecom", "wecom_callback", "weixin", "bluebubbles", "qqbot", "yuanbao"} -# ``None`` → aiohttp binds BOTH address families. "0.0.0.0" is IPv4-only and was -# unreachable on IPv6-only networks (Fly.io 6PN); "::" becomes IPv6-only where the -# kernel sets IPV6_V6ONLY=1, breaking the 127.0.0.1 health check. Users can pin a -# host via ``platforms.webhook.extra.host``. +# ``None`` → aiohttp binds BOTH address families. "0.0.0.0" is IPv4-only (unreachable on IPv6-only +# networks such as Fly.io 6PN); "::" becomes IPv6-only where the kernel sets IPV6_V6ONLY=1, breaking +# the 127.0.0.1 health check. Users can pin a host via ``platforms.webhook.extra.host``. DEFAULT_HOST = None DEFAULT_PORT = 8644 _INSECURE_NO_AUTH = "INSECURE_NO_AUTH" @@ -77,12 +76,17 @@ def _hex_hmac(secret: str, data: bytes) -> str: return hmac.new(secret.encode(), data, hashlib.sha256).hexdigest() -def _timestamp_age(raw: str) -> Optional[int]: - """Seconds between now and an integer timestamp header; None when unparseable.""" +def _timestamp_fresh(raw: str, stale_msg: str, *args) -> bool: + """True when integer timestamp header *raw* is within the replay window; unparseable → False, + stale → warn ``stale_msg % args`` and False.""" try: - return abs(int(time.time()) - int(raw)) + age = abs(int(time.time()) - int(raw)) except (TypeError, ValueError): - return None + return False + if age > _V2_REPLAY_WINDOW_SECONDS: + logger.warning(stale_msg, *args) + return False + return True def _is_known_platform(name: str) -> bool: @@ -100,8 +104,7 @@ def _json_error(message: str, status: int) -> "web.Response": def _peek_session_id(store, session_key: str): - """Prefer the store's lock-held accessor; fall back to the private path only for - older stores / test doubles that predate it.""" + """Prefer the store's lock-held accessor; the private-path fallback is for older stores / test doubles.""" peek = getattr(store, "peek_session_id", None) if callable(peek): return peek(session_key) @@ -121,11 +124,7 @@ def _validate_svix_signature(body: bytes, secret: str, msg_id: str, timestamp: s """Svix-compatible signatures (AgentMail): base64 HMAC-SHA256 of "{id}.{timestamp}.{body}".""" if not (msg_id and timestamp and signature_header and secret): return False - age = _timestamp_age(timestamp) - if age is None: - return False - if age > _V2_REPLAY_WINDOW_SECONDS: - logger.warning("[webhook] Svix signature timestamp outside replay window") + if not _timestamp_fresh(timestamp, "[webhook] Svix signature timestamp outside replay window"): return False if secret.startswith("whsec_"): try: @@ -150,8 +149,7 @@ def _validate_svix_signature(body: bytes, secret: str, msg_id: str, timestamp: s class WebhookAdapter(BasePlatformAdapter): """Generic webhook receiver that triggers agent runs from HTTP POSTs.""" - # Event-triggered, no human present: the startup auto-resume turn must FINISH - # the interrupted work instead of asking "what next?". + # Event-triggered, no human present: startup auto-resume must FINISH the interrupted work, not ask "what next?". interactive_resume: bool = False def __init__(self, config: PlatformConfig): @@ -166,21 +164,18 @@ class WebhookAdapter(BasePlatformAdapter): self._dynamic_routes_mtime: float = 0.0 self._routes: Dict[str, dict] = dict(self._static_routes) self._runner = None - # Routes already warned about legacy V1 body-only signatures (once per route). - self._v1_signature_warned: set[str] = set() - # Keyed by session chat_id; read by EVERY send() (interim status messages AND - # the final response) so never pop on send(). TTL-pruned on each POST. + self._v1_signature_warned: set[str] = set() # routes already warned about legacy V1 (once per route) + # Keyed by session chat_id; read by EVERY send() (interim status messages AND the final + # response) so never pop on send(). TTL-pruned on each POST. self._delivery_info: Dict[str, dict] = {} self._delivery_info_created: Dict[str, float] = {} self._delivery_info_order: Deque[tuple[float, str]] = deque() - # Gateway runner for cross-platform delivery (set externally). - self.gateway_runner = None + self.gateway_runner = None # set externally; needed for cross-platform delivery # Idempotency: TTL cache of recently processed delivery IDs. self._seen_deliveries: Dict[str, float] = {} self._idempotency_ttl: int = 3600 # 1 hour self._seen_deliveries_next_prune_at: float = 0.0 - # Rate limiting: per-route timestamps in a fixed window. - self._rate_counts: Dict[str, Deque[float]] = {} + self._rate_counts: Dict[str, Deque[float]] = {} # per-route hit timestamps in a fixed window self._rate_limit: int = int(extra.get("rate_limit", 30)) # per minute self._max_body_bytes: int = int(extra.get("max_body_bytes", 1_048_576)) # 1MB self._script_timeout_seconds: int = int(extra.get("script_timeout_seconds", DEFAULT_SCRIPT_TIMEOUT_SECONDS)) @@ -189,8 +184,7 @@ class WebhookAdapter(BasePlatformAdapter): # --- Lifecycle --- def _validate_route(self, name: str, route: dict) -> None: - """Startup validation: secret is required; INSECURE_NO_AUTH only on loopback - (an unauthenticated route on a public interface is a footgun — crash early).""" + """Startup validation: secret required; INSECURE_NO_AUTH only on loopback (crash early on a public footgun).""" secret = route.get("secret", self._global_secret) if not secret: raise ValueError( @@ -215,22 +209,20 @@ class WebhookAdapter(BasePlatformAdapter): self._reload_dynamic_routes() for name, route in self._routes.items(): self._validate_route(name, route) - # client_max_size enforces the cap on every read path, including chunked - # bodies without Content-Length that bypass the header check. + # client_max_size enforces the cap on every read path, including chunked bodies without + # Content-Length that bypass the header check. app = web.Application(client_max_size=self._max_body_bytes) app.router.add_get("/health", self._handle_health) app.router.add_post("/webhooks/{route_name}", self._handle_webhook) - # /p// prefix routes the event to that profile (only honored when - # gateway.multiplex_profiles is on; the handler validates). + # /p// routes the event to that profile (honored only under gateway.multiplex_profiles). app.router.add_post("/p/{profile}/webhooks/{route_name}", self._handle_webhook) self._runner = web.AppRunner(app) await self._runner.setup() - # SO_REUSEADDR: on macOS (BSD) two wildcard/specific sockets can silently split traffic - # while both report success → disable. On Linux it only permits rebinding past TIME_WAIT - # (a quick restart would otherwise fail to bind for ~60s) → keep the default. - site = web.TCPSite( - self._runner, self._host, self._port, reuse_address=False if sys.platform == "darwin" else None, - ) + # SO_REUSEADDR: on macOS (BSD) two wildcard/specific sockets can silently split traffic while + # both report success → disable. On Linux it only permits rebinding past TIME_WAIT (a quick + # restart would otherwise fail to bind for ~60s) → keep the default. + site = web.TCPSite(self._runner, self._host, self._port, + reuse_address=False if sys.platform == "darwin" else None) try: await site.start() except OSError as exc: @@ -255,14 +247,13 @@ class WebhookAdapter(BasePlatformAdapter): self._mark_disconnected() logger.info("[webhook] Disconnected") - async def send( - self, chat_id: str, content: str, reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None) -> SendResult: + async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None) -> SendResult: """Deliver the agent's response to the destination stored for ``chat_id`` (``webhook:{route}:{delivery_id}``) — read with ``.get()``, never popped.""" - # Autonomous lane (no human reader): the loose marker matcher shared with cron — - # marker on its own first/last line — because models add a sentence explaining - # why they stayed quiet, which the interactive exact-match rule would deliver. + # Autonomous lane (no human reader): the loose marker matcher shared with cron (marker on its own + # first/last line), because models add a sentence explaining why they stayed quiet, which the + # interactive exact-match rule would deliver. if is_autonomous_silence_response(content): logger.info("[webhook] Response for %s is a silence marker — not delivering", chat_id) return SendResult(success=True) @@ -279,8 +270,8 @@ class WebhookAdapter(BasePlatformAdapter): return SendResult(success=False, error=f"Unknown deliver type: {deliver_type}") def _prune_delivery_info(self, now: float) -> None: - """Drop delivery_info entries older than the idempotency TTL (bounds the dict - by ``rate_limit * TTL`` even when runs never produce a final response).""" + """Drop delivery_info entries older than the idempotency TTL (bounds the dict by ``rate_limit * TTL`` + even when runs never produce a final response).""" created = self._delivery_info_created if len(self._delivery_info_order) < len(created): self._delivery_info_order = deque((at, key) for key, at in sorted(created.items(), key=lambda kv: kv[1])) @@ -329,17 +320,15 @@ class WebhookAdapter(BasePlatformAdapter): return {"name": chat_id, "type": "webhook"} def toolsets_for_source(self, source) -> Optional[List[str]]: - """Per-route ``toolsets`` override (config.yaml or a manual key in - webhook_subscriptions.json — deliberately NOT settable via `hermes webhook - subscribe`, so an agent-created subscription cannot self-grant tools).""" + """Per-route ``toolsets`` override (config.yaml or a manual key in webhook_subscriptions.json — + deliberately NOT settable via `hermes webhook subscribe`, so an agent-created subscription + cannot self-grant tools).""" parts = str(getattr(source, "chat_id", "") or "").split(":", 2) if len(parts) < 2 or parts[0] != "webhook": return None route_config = self._routes.get(parts[1]) - if not isinstance(route_config, dict): - return None - toolsets = route_config.get("toolsets") - if not isinstance(toolsets, list) or not toolsets: + toolsets = route_config.get("toolsets") if isinstance(route_config, dict) else None + if not isinstance(toolsets, list): return None return [str(t).strip() for t in toolsets if str(t).strip()] or None @@ -350,21 +339,16 @@ class WebhookAdapter(BasePlatformAdapter): return web.json_response({"status": "ok", "platform": "webhook"}) def _dynamic_route_allowed(self, name: str, route: dict) -> bool: - """An empty effective secret would make _handle_webhook skip HMAC validation → - reject such dynamic routes; INSECURE_NO_AUTH is loopback-only.""" + """An empty effective secret would make _handle_webhook skip HMAC validation → reject such + dynamic routes; INSECURE_NO_AUTH is loopback-only.""" effective_secret = route.get("secret", self._global_secret) if not effective_secret: - logger.warning( - "[webhook] Dynamic route '%s' skipped: 'secret' is " - "missing or empty. Set a valid HMAC secret, or use " - "'%s' to explicitly disable auth (testing only).", - name, _INSECURE_NO_AUTH) + logger.warning("[webhook] Dynamic route '%s' skipped: 'secret' is missing or empty. Set a valid HMAC " + "secret, or use '%s' to explicitly disable auth (testing only).", name, _INSECURE_NO_AUTH) return False if effective_secret == _INSECURE_NO_AUTH and not _is_loopback_host(self._host): - logger.warning( - "[webhook] Dynamic route '%s' skipped: INSECURE_NO_AUTH " - "is only allowed on loopback hosts. Current host: '%s'.", - name, self._host) + logger.warning("[webhook] Dynamic route '%s' skipped: INSECURE_NO_AUTH is only allowed on loopback " + "hosts. Current host: '%s'.", name, self._host) return False return True @@ -385,10 +369,8 @@ class WebhookAdapter(BasePlatformAdapter): data = json.loads(subs_path.read_text(encoding="utf-8")) if not isinstance(data, dict): return - # Static routes take precedence. - self._dynamic_routes = { - k: v for k, v in data.items() - if k not in self._static_routes and self._dynamic_route_allowed(k, v)} + self._dynamic_routes = { # static routes take precedence + k: v for k, v in data.items() if k not in self._static_routes and self._dynamic_route_allowed(k, v)} self._routes = {**self._dynamic_routes, **self._static_routes} self._dynamic_routes_mtime = mtime logger.info("[webhook] Reloaded %d dynamic route(s): %s", @@ -405,9 +387,8 @@ class WebhookAdapter(BasePlatformAdapter): return None cfg = getattr(self.gateway_runner, "config", None) if not getattr(cfg, "multiplex_profiles", False): - # Only a self-referential prefix may fall through to the bare route; - # anything else fails closed (silently ignoring the prefix served the - # owner's routes under another profile's URL). + # Only a self-referential prefix may fall through to the bare route; anything else fails + # closed (silently ignoring the prefix served the owner's routes under another profile's URL). with suppress(Exception): from hermes_cli.profiles import profile_matches_home if profile_matches_home(profile): @@ -438,25 +419,22 @@ class WebhookAdapter(BasePlatformAdapter): from hermes_cli.profiles import get_profile_dir return _profile_runtime_scope(get_profile_dir(profile)) - async def _read_authenticated_body( - self, request: "web.Request", route_name: str, route_config: dict - ) -> "tuple[Optional[bytes], Optional[web.Response]]": + async def _read_authenticated_body(self, request: "web.Request", route_name: str, + route_config: dict) -> "tuple[Optional[bytes], Optional[web.Response]]": """Auth-before-body: size-cap, read, then HMAC-validate. Returns ``(body, None)`` or ``(None, response)``.""" if (request.content_length or 0) > self._max_body_bytes: return None, _json_error("Payload too large", 413) try: raw_body = await request.read() - except web.HTTPRequestEntityTooLarge: - # aiohttp's client_max_size tripped — chunked or lying Content-Length. + except web.HTTPRequestEntityTooLarge: # client_max_size tripped — chunked or lying Content-Length return None, _json_error("Payload too large", 413) except Exception as e: logger.error("[webhook] Failed to read body: %s", e) return None, _json_error("Bad request", 400) - if len(raw_body) > self._max_body_bytes: - # Defense in depth if the server-level limit was bypassed or misconfigured. + if len(raw_body) > self._max_body_bytes: # defense in depth if the server-level limit was bypassed return None, _json_error("Payload too large", 413) - # Missing/empty secrets fail closed here too (not only in connect()), so - # direct handler reuse cannot become an unauthenticated dispatch surface. + # Missing/empty secrets fail closed here too (not only in connect()), so direct handler reuse + # cannot become an unauthenticated dispatch surface. secret = route_config.get("secret", self._global_secret) if not secret: logger.error("[webhook] Route %s has no HMAC secret; refusing request", route_name) @@ -478,11 +456,10 @@ class WebhookAdapter(BasePlatformAdapter): except Exception: return _UNPARSEABLE - async def _handle_deliver_only( - self, prompt: str, payload: Any, route_config: dict, route_name: str, event_type: str, delivery_id: str - ) -> "web.Response": - """deliver_only: the rendered prompt IS the message — skip the agent, reuse - the same auth/rate-limit/idempotency/template pipeline.""" + async def _handle_deliver_only(self, prompt: str, payload: Any, route_config: dict, route_name: str, + event_type: str, delivery_id: str) -> "web.Response": + """deliver_only: the rendered prompt IS the message — skip the agent, reuse the same + auth/rate-limit/idempotency/template pipeline.""" delivery = { "deliver": route_config.get("deliver", "log"), "deliver_extra": self._render_delivery_extra(route_config.get("deliver_extra", {}), payload), @@ -500,14 +477,13 @@ class WebhookAdapter(BasePlatformAdapter): {"status": "delivered", "route": route_name, "target": delivery["deliver"], "delivery_id": delivery_id}, status=200) # Target rejected it — 502 with a generic error (don't leak adapter detail). - logger.warning("[webhook] direct-deliver target rejected route=%s target=%s error=%s", - route_name, delivery["deliver"], result.error) + logger.warning("[webhook] direct-deliver target rejected route=%s target=%s error=%s", route_name, + delivery["deliver"], result.error) return web.json_response(failed, status=502) def _resolve_route(self, request: "web.Request") -> "tuple[str, Optional[dict], Any, Optional[web.Response]]": """Route + profile lookup for a POST; ``(route_name, route_config, profile, error_response)``.""" - # Hot-reload dynamic subscriptions on each request (mtime-gated, cheap) - self._reload_dynamic_routes() + self._reload_dynamic_routes() # hot-reload dynamic subscriptions (mtime-gated, cheap) route_name = request.match_info.get("route_name", "") route_config = self._routes.get(route_name) profile = self._resolve_request_profile(request) @@ -519,16 +495,16 @@ class WebhookAdapter(BasePlatformAdapter): logger.warning("[webhook] Route %s is not authorized for profile %r", route_name, profile or "default") # Same as unknown-route so profile mismatches can't enumerate route bindings. return route_name, None, profile, _json_error(f"Unknown route: {route_name}", 404) - # Disabled routes stay in the subscriptions file (dashboard can re-enable) - # but reject events. Only an explicit ``enabled: false`` turns a route off. + # Disabled routes stay in the subscriptions file (dashboard can re-enable) but reject events. + # Only an explicit ``enabled: false`` turns a route off. if route_config.get("enabled", True) is False: return route_name, None, profile, _json_error(f"Route disabled: {route_name}", 403) return route_name, route_config, profile, None @staticmethod def _apply_skills(prompt: str, skills: list) -> str: - """Inject the first matching skill via build_skill_invocation_message() directly — - /skill-name slash commands would be intercepted by the command parser.""" + """Inject the first matching skill via build_skill_invocation_message() directly — /skill-name slash + commands would be intercepted by the command parser.""" try: from agent.skill_commands import build_skill_invocation_message, get_skill_commands skill_cmds = get_skill_commands() @@ -557,16 +533,12 @@ class WebhookAdapter(BasePlatformAdapter): payload = self._parse_body(raw_body) if payload is _UNPARSEABLE: return _json_error("Cannot parse body", 400) - event_type = ( - request.headers.get("X-GitHub-Event", "") - or request.headers.get("X-GitLab-Event", "") - or payload.get("event_type", "") - or payload.get("type", "") - or "unknown") + headers = request.headers + event_type = (headers.get("X-GitHub-Event", "") or headers.get("X-GitLab-Event", "") + or payload.get("event_type", "") or payload.get("type", "") or "unknown") allowed_events = route_config.get("events", []) if allowed_events and event_type not in allowed_events: - logger.debug("[webhook] Ignoring event %s for route %s (allowed: %s)", - event_type, route_name, allowed_events) + logger.debug("[webhook] Ignoring event %s for route %s (allowed: %s)", event_type, route_name, allowed_events) return web.json_response({"status": "ignored", "event": event_type}) if not self._route_processor.route_filters_match(route_config, payload, event_type, request.headers): logger.info("[webhook] filtered event=%s route=%s", event_type, route_name) @@ -578,21 +550,17 @@ class WebhookAdapter(BasePlatformAdapter): if script: # Shells out (up to its timeout) — worker thread so the loop isn't blocked; to_thread # copies contextvars so the profile scope follows. - keep, transformed_payload = await asyncio.to_thread( - self._route_processor.run_route_script, script, payload) + keep, transformed_payload = await asyncio.to_thread(self._route_processor.run_route_script, script, payload) if not keep: logger.info("[webhook] script ignored event=%s route=%s", event_type, route_name) return web.json_response({"status": "ignored", "reason": "script", "route": route_name}) payload = transformed_payload or payload prompt = self._render_prompt(route_config.get("prompt", ""), payload, event_type, route_name) - skills = route_config.get("skills", []) - if skills: + if skills := route_config.get("skills", []): prompt = self._apply_skills(prompt, skills) - headers = request.headers delivery_id = headers.get("X-GitHub-Delivery", headers.get( "svix-id", headers.get("X-Request-ID", str(int(time.time() * 1000))))) - # Idempotency: skip duplicate deliveries (webhook retries). - now = time.time() + now = time.time() # idempotency: skip duplicate deliveries (webhook retries) if not self._record_delivery_id(delivery_id, now): logger.info("[webhook] Skipping duplicate delivery %s", delivery_id) return web.json_response({"status": "duplicate", "delivery_id": delivery_id}, status=200) @@ -601,13 +569,10 @@ class WebhookAdapter(BasePlatformAdapter): return self._dispatch_agent_run(request, route_config, route_name, profile, payload, prompt, event_type, delivery_id, now) - def _dispatch_agent_run( - self, request, route_config: dict, route_name: str, profile, payload: Any, prompt: str, - event_type: str, delivery_id: str, now: float, - ) -> "web.Response": + def _dispatch_agent_run(self, request, route_config: dict, route_name: str, profile, payload: Any, prompt: str, + event_type: str, delivery_id: str, now: float) -> "web.Response": """Record delivery info, spawn the agent run, and return 202 immediately.""" - # delivery_id in the session key → concurrent webhooks on one route get - # independent agent runs (not queued/interrupted). + # delivery_id in the session key → concurrent webhooks on one route get independent runs. session_chat_id = f"webhook:{route_name}:{delivery_id}" self._delivery_info[session_chat_id] = { "deliver": route_config.get("deliver", "log"), @@ -622,10 +587,10 @@ class WebhookAdapter(BasePlatformAdapter): source.profile = profile event = MessageEvent( text=prompt, message_type=MessageType.TEXT, source=source, raw_message=payload, message_id=delivery_id) - logger.info("[webhook] %s event=%s route=%s prompt_len=%d delivery=%s", - request.method, event_type, route_name, len(prompt), delivery_id) - # The per-delivery session is closed by ``on_processing_complete`` once the run - # finishes (``handle_message`` is fire-and-forget, so nothing can be closed here). + logger.info("[webhook] %s event=%s route=%s prompt_len=%d delivery=%s", request.method, event_type, route_name, + len(prompt), delivery_id) + # The per-delivery session is closed by ``on_processing_complete`` once the run finishes + # (``handle_message`` is fire-and-forget, so nothing can be closed here). task = asyncio.create_task(self.handle_message(event)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) @@ -642,8 +607,7 @@ class WebhookAdapter(BasePlatformAdapter): """Mark the per-delivery session ended via ``SessionDB.end_session`` (never a hand-written UPDATE), resolving session_id from the SAME source the run was keyed on.""" runner = self.gateway_runner - session_db = getattr(runner, "_session_db", None) - store = getattr(runner, "session_store", None) + session_db, store = getattr(runner, "_session_db", None), getattr(runner, "session_store", None) key_fn = getattr(runner, "_session_key_for_source", None) if runner is None or session_db is None or store is None or key_fn is None: return @@ -683,25 +647,19 @@ class WebhookAdapter(BasePlatformAdapter): if provided: return _hmac_str_equal(provided, expected()) route_name = request.match_info.get("route_name", "") - # Generic V2: X-Webhook-Signature-V2 = hex HMAC-SHA256 of ".", - # X-Webhook-Timestamp required. Presence of the V2 header COMMITS to V2 — it - # must not fall through to V1 on a missing/bad timestamp, or an attacker could - # strip the timestamp from a captured mixed V1+V2 request and replay it - # against the still-present body-only V1 signature. + # Generic V2: X-Webhook-Signature-V2 = hex HMAC-SHA256 of ".", X-Webhook-Timestamp + # required. Presence of the V2 header COMMITS to V2 — it must not fall through to V1 on a + # missing/bad timestamp, or an attacker could strip the timestamp from a captured mixed V1+V2 + # request and replay it against the still-present body-only V1 signature. v2_sig = headers.get("X-Webhook-Signature-V2", "") if v2_sig: v2_timestamp = headers.get("X-Webhook-Timestamp", "") if not v2_timestamp: - logger.warning( - "[webhook] Route '%s' sent X-Webhook-Signature-V2 with " - "no X-Webhook-Timestamp — rejecting rather than falling back to legacy V1", - route_name) + logger.warning("[webhook] Route '%s' sent X-Webhook-Signature-V2 with no X-Webhook-Timestamp — " + "rejecting rather than falling back to legacy V1", route_name) return False - age = _timestamp_age(v2_timestamp) - if age is None: - return False - if age > _V2_REPLAY_WINDOW_SECONDS: - logger.warning("[webhook] Route '%s' generic HMAC V2 timestamp outside replay window", route_name) + if not _timestamp_fresh(v2_timestamp, "[webhook] Route '%s' generic HMAC V2 timestamp outside replay window", + route_name): return False return _hmac_str_equal(v2_sig, _hex_hmac(secret, v2_timestamp.encode() + b"." + body)) # Generic V1 (legacy, deprecated): body-only HMAC → replays indefinitely. @@ -709,12 +667,9 @@ class WebhookAdapter(BasePlatformAdapter): if generic_sig: if route_name not in self._v1_signature_warned: self._v1_signature_warned.add(route_name) - logger.warning( - "[webhook] Route '%s' uses legacy body-only HMAC (no " - "timestamp), which is vulnerable to replay attacks. Add " - "an 'X-Webhook-Timestamp' header and switch to " - "'X-Webhook-Signature-V2' (HMAC-SHA256 of '.').", - route_name) + logger.warning("[webhook] Route '%s' uses legacy body-only HMAC (no timestamp), which is vulnerable " + "to replay attacks. Add an 'X-Webhook-Timestamp' header and switch to " + "'X-Webhook-Signature-V2' (HMAC-SHA256 of '.').", route_name) return _hmac_str_equal(generic_sig, _hex_hmac(secret, body)) logger.debug("[webhook] Secret configured but no signature header found") return False @@ -747,17 +702,15 @@ class WebhookAdapter(BasePlatformAdapter): def _render_delivery_extra(self, extra: dict, payload: dict) -> dict: """Render delivery_extra template values with payload data.""" - return { - key: self._render_prompt(value, payload, "", "") if isinstance(value, str) else value - for key, value in extra.items()} + return {key: self._render_prompt(value, payload, "", "") if isinstance(value, str) else value + for key, value in extra.items()} # --- Response delivery --- async def _direct_deliver(self, content: str, delivery: dict) -> SendResult: """deliver_only: dispatch *content* to the same delivery helpers agent-mode ``send()`` uses.""" deliver_type = delivery.get("deliver", "log") - if deliver_type == "log": - # Startup validation rejects deliver_only + log, but guard defensively. + if deliver_type == "log": # startup validation rejects deliver_only + log; guard defensively logger.info("[webhook] direct-deliver log-only: %s", content[:200]) return SendResult(success=True) if deliver_type == "github_comment": @@ -767,13 +720,11 @@ class WebhookAdapter(BasePlatformAdapter): async def _deliver_github_comment(self, content: str, delivery: dict) -> SendResult: """Post agent response as a GitHub PR/issue comment via ``gh`` CLI.""" extra = delivery.get("deliver_extra", {}) - repo = extra.get("repo", "") - pr_number = extra.get("pr_number", "") + repo, pr_number = extra.get("repo", ""), extra.get("pr_number", "") if not repo or not pr_number: logger.error("[webhook] github_comment delivery missing repo or pr_number") return SendResult(success=False, error="Missing repo or pr_number") - # Input validation (prevent CLI argument injection). - try: + try: # input validation (prevent CLI argument injection) pr_int = int(pr_number) if pr_int <= 0: raise ValueError("non-positive") @@ -784,11 +735,10 @@ class WebhookAdapter(BasePlatformAdapter): logger.error("[webhook] invalid repo format: %r", repo) return SendResult(success=False, error="Invalid repo format") try: - # Off-loop: `gh` does network I/O up to its 30s timeout; inline it froze - # every adapter and timer on the gateway event loop. + # Off-loop: `gh` does network I/O up to its 30s timeout; inline it froze every adapter and + # timer on the gateway event loop. result = await asyncio.to_thread( - subprocess.run, - ["gh", "pr", "comment", str(pr_int), "--repo", repo, "--body", content], + subprocess.run, ["gh", "pr", "comment", str(pr_int), "--repo", repo, "--body", content], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30) if result.returncode == 0: logger.info("[webhook] Posted comment on %s#%s", repo, pr_number) @@ -803,8 +753,7 @@ class WebhookAdapter(BasePlatformAdapter): return SendResult(success=False, error=str(e)) def _find_adapter(self, target_platform: Platform): - """Default adapters first; multiplex may park a platform only on a secondary - profile (_profile_adapters), so fall back to those.""" + """Default adapters first; multiplex may park a platform only on a secondary profile (_profile_adapters).""" adapter = self.gateway_runner.adapters.get(target_platform) if adapter: return adapter @@ -831,7 +780,5 @@ class WebhookAdapter(BasePlatformAdapter): if not home: return SendResult(success=False, error=f"No chat_id or home channel for {platform_name}") chat_id = home.chat_id - # thread_id from deliver_extra so Telegram forum topics work. - thread_id = extra.get("message_thread_id") or extra.get("thread_id") - metadata = {"thread_id": thread_id} if thread_id else None - return await adapter.send(chat_id, content, metadata=metadata) + thread_id = extra.get("message_thread_id") or extra.get("thread_id") # Telegram forum topics + return await adapter.send(chat_id, content, metadata={"thread_id": thread_id} if thread_id else None) From 1005056619759eca68149f369be6d0dd46f92ec9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:24:40 -0700 Subject: [PATCH 03/12] =?UTF-8?q?refactor(gateway/platforms):=20bluebubble?= =?UTF-8?q?s.py=20second=20pass=20=E2=80=94=20walrus=20guards,=20fold=20la?= =?UTF-8?q?youts,=20compact=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/bluebubbles.py | 194 ++++++++++++------------------- gateway/platforms/signal.py | 3 +- gateway/platforms/webhook.py | 10 +- 3 files changed, 84 insertions(+), 123 deletions(-) diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index b0bfc5f179..b51b9dd0be 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -1,8 +1,5 @@ -"""BlueBubbles iMessage platform adapter. - -Uses the local BlueBubbles macOS server for outbound REST sends and inbound -webhooks: text, media attachments, typing indicators, and read receipts. -""" +"""BlueBubbles iMessage platform adapter: local BlueBubbles macOS server for outbound REST sends and +inbound webhooks (text, media attachments, typing indicators, read receipts).""" import asyncio import json @@ -28,9 +25,8 @@ from .media_cache import ext_for_mime from gateway.platforms.helpers import compile_mention_patterns, strip_markdown from utils import TRUTHY_STRINGS -# Historical BlueBubbles mime→ext maps, preserved verbatim as overrides for -# the shared dispatch in gateway.platforms.media_cache. Both maps are -# CLOSED: unlisted mimes fall back to .jpg / .mp3 (never mimetypes). +# Historical BlueBubbles mime→ext maps, preserved verbatim as overrides for the shared dispatch in +# gateway.platforms.media_cache. Both maps are CLOSED: unlisted mimes fall back to .jpg / .mp3. _BLUEBUBBLES_IMAGE_EXT_OVERRIDES = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/heic": ".jpg", "image/heif": ".jpg", "image/tiff": ".jpg", # historical mapping @@ -44,23 +40,20 @@ _BLUEBUBBLES_AUDIO_EXT_OVERRIDES = { logger = logging.getLogger(__name__) DEFAULT_WEBHOOK_HOST = "127.0.0.1" -# Webhook events are small JSON/form payloads; attachments come through the -# REST API. 1 MiB keeps oversized/chunked bodies from buffering unbounded. +# Webhook events are small JSON/form payloads (attachments come through the REST API); 1 MiB keeps +# oversized/chunked bodies from buffering unbounded. _WEBHOOK_MAX_BODY_BYTES = 1_048_576 DEFAULT_WEBHOOK_PORT = 8645 DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook" MAX_TEXT_LENGTH = 4000 -# iMessage has no stable bot mention identity (unlike <@U...>/@botname/MXID), -# so `require_mention: true` without custom aliases uses Hermes wake words. +# iMessage has no stable bot mention identity (unlike <@U...>/@botname/MXID), so +# `require_mention: true` without custom aliases uses Hermes wake words. DEFAULT_MENTION_PATTERNS = [r"(? bool: def _normalize_server_url(raw: str) -> str: value = (raw or "").strip() - if not value: - return "" - if not re.match(r"^https?://", value, flags=re.I): + if value and not re.match(r"^https?://", value, flags=re.I): value = f"http://{value}" return value.rstrip("/") def _closed_ext(mime: str, overrides: Dict[str, str], fallback: str) -> str: """Historical maps were closed: unlisted mimes fall back without consulting mimetypes.""" - return ext_for_mime( - mime, overrides=overrides, use_defaults=False, use_mimetypes=False, fallback=fallback - ) or fallback + return ext_for_mime(mime, overrides=overrides, use_defaults=False, use_mimetypes=False, + fallback=fallback) or fallback def _setting(extra: Dict[str, Any], key: str, env: str, default: str = "") -> Any: @@ -137,8 +127,7 @@ class BlueBubblesAdapter(BasePlatformAdapter): _require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION") self.require_mention = str(_require_mention).strip().lower() in TRUTHY_STRINGS self._mention_patterns = self._compile_mention_patterns( - extra["mention_patterns"] if "mention_patterns" in extra else os.getenv("BLUEBUBBLES_MENTION_PATTERNS") - ) + extra["mention_patterns"] if "mention_patterns" in extra else os.getenv("BLUEBUBBLES_MENTION_PATTERNS")) self.client: Optional[httpx.AsyncClient] = None self._runner = None self._private_api_enabled: Optional[bool] = None @@ -153,25 +142,25 @@ class BlueBubblesAdapter(BasePlatformAdapter): @staticmethod def _compile_mention_patterns(raw: Any) -> List[re.Pattern]: - """Compile group-mention wake words; ``raw`` is a list, a raw env string - (JSON list or comma/newline-separated), or None (Hermes defaults).""" - return compile_mention_patterns(raw, log_prefix="bluebubbles", defaults=DEFAULT_MENTION_PATTERNS, logger_=logger) + """Compile group-mention wake words; ``raw`` is a list, a raw env string (JSON list or + comma/newline-separated), or None (Hermes defaults).""" + return compile_mention_patterns(raw, log_prefix="bluebubbles", defaults=DEFAULT_MENTION_PATTERNS, + logger_=logger) def _message_matches_mention_patterns(self, text: str) -> bool: return bool(text) and any(pattern.search(text) for pattern in self._mention_patterns) def _clean_mention_text(self, text: str) -> str: - """Strip a leading wake word only — patterns are regexes, so stripping - anywhere later in the prompt could delete ordinary words.""" - if not text: - return text + """Strip a leading wake word only — patterns are regexes, so stripping anywhere later in the + prompt could delete ordinary words.""" + stripped = (text or "").lstrip() for pattern in self._mention_patterns: - match = pattern.match(text.lstrip()) - if match: - return text.lstrip()[match.end():].lstrip(" ,:-") or text + if match := pattern.match(stripped): + return stripped[match.end():].lstrip(" ,:-") or text return text async def _api_json(self, method: str, path: str, **kwargs) -> Dict[str, Any]: + """Authenticated request to the BlueBubbles REST API; raises on HTTP errors, returns decoded JSON.""" assert self.client is not None res = await getattr(self.client, method)(self._api_url(path), **kwargs) res.raise_for_status() @@ -198,8 +187,7 @@ class BlueBubblesAdapter(BasePlatformAdapter): if not self._private_api_enabled or not self._helper_connected or not self.client: return False with suppress(Exception): - guid = await self._resolve_chat_guid(chat_id) - if guid: + if guid := await self._resolve_chat_guid(chat_id): url = self._api_url(f"/api/v1/chat/{quote(guid, safe='')}/{action}") await getattr(self.client, method)(url, timeout=5) return True @@ -227,22 +215,21 @@ class BlueBubblesAdapter(BasePlatformAdapter): logger.error("[bluebubbles] cannot reach server at %s: %s", self.server_url, exc) await self._close_client() return False - # client_max_size makes aiohttp enforce the cap on every read path, - # including chunked requests with no Content-Length. + # client_max_size makes aiohttp enforce the cap on every read path, incl. chunked requests + # with no Content-Length. app = web.Application(client_max_size=_WEBHOOK_MAX_BODY_BYTES) app.router.add_get("/health", lambda _: web.Response(text="ok")) app.router.add_post(self.webhook_path, self._handle_webhook) - # The webhook auth value rides in the query string (BlueBubbles cannot - # send custom headers) — keep it out of aiohttp access logs. + # The webhook auth value rides in the query string (BlueBubbles cannot send custom headers) + # — keep it out of aiohttp access logs. self._runner = web.AppRunner(app, access_log=None) await self._runner.setup() site = web.TCPSite(self._runner, self.webhook_host, self.webhook_port) await site.start() self._mark_connected() - logger.info("[bluebubbles] webhook listening on http://%s:%s%s", - self.webhook_host, self.webhook_port, self.webhook_path) - # The server only sends events to webhooks registered via its API. - await self._register_webhook() + logger.info("[bluebubbles] webhook listening on http://%s:%s%s", self.webhook_host, self.webhook_port, + self.webhook_path) + await self._register_webhook() # the server only sends events to webhooks registered via its API # Plugin-registered native handlers (ctx.register_platform_handler). self._wire_plugin_handlers(None) return True @@ -267,16 +254,13 @@ class BlueBubblesAdapter(BasePlatformAdapter): return f"http://{host}:{self.webhook_port}{self.webhook_path}" def _webhook_register_url_with(self, password_param: str) -> str: - base = self._webhook_url - return f"{base}?password={password_param}" if self.password else base + return f"{self._webhook_url}?password={password_param}" if self.password else self._webhook_url @property def _webhook_register_url(self) -> str: - """Registered webhook URL, password embedded as a query param. - - BlueBubbles posts to the exact registered URL and its registration - API cannot set custom headers, so this is the only way to - authenticate inbound webhooks without disabling auth.""" + """Registered webhook URL with the password as a query param: BlueBubbles posts to the exact + registered URL and cannot set custom headers, so this is the only way to authenticate inbound + webhooks without disabling auth.""" return self._webhook_register_url_with(quote(self.password, safe="")) @property @@ -292,18 +276,17 @@ class BlueBubblesAdapter(BasePlatformAdapter): return [] async def _register_webhook(self) -> bool: - """Register this webhook URL, reusing an existing registration if present - (crash resilience — avoids duplicates after an unclean shutdown).""" + """Register this webhook URL, reusing an existing registration if present (crash resilience — + avoids duplicates after an unclean shutdown).""" if not self.client: return False - webhook_url = self._webhook_register_url - log_url = self._webhook_register_url_for_log + webhook_url, log_url = self._webhook_register_url, self._webhook_register_url_for_log if await self._find_registered_webhooks(webhook_url): logger.info("[bluebubbles] webhook already registered: %s", log_url) return True - payload = {"url": webhook_url, "events": ["new-message", "updated-message"]} try: - res = await self._api_post("/api/v1/webhook", payload) + res = await self._api_post("/api/v1/webhook", + {"url": webhook_url, "events": ["new-message", "updated-message"]}) status = res.get("status", 0) if 200 <= status < 300: logger.info("[bluebubbles] webhook registered with server: %s", log_url) @@ -321,10 +304,8 @@ class BlueBubblesAdapter(BasePlatformAdapter): removed = False try: for wh in await self._find_registered_webhooks(self._webhook_register_url): - wh_id = wh.get("id") - if wh_id: - res = await self.client.delete(self._api_url(f"/api/v1/webhook/{wh_id}")) - res.raise_for_status() + if wh_id := wh.get("id"): + (await self.client.delete(self._api_url(f"/api/v1/webhook/{wh_id}"))).raise_for_status() removed = True if removed: logger.info("[bluebubbles] webhook unregistered: %s", self._webhook_register_url_for_log) @@ -335,13 +316,10 @@ class BlueBubblesAdapter(BasePlatformAdapter): # --- Chat GUID resolution --- async def _resolve_chat_guid(self, target: str) -> Optional[str]: - """Resolve an email/phone to a chat GUID (raw ``a;-;b`` GUIDs pass through). - - Matches strictly on ``chatIdentifier`` / ``identifier``. Participant - membership is intentionally NOT a fallback: the same contact appears in - a 1:1 DM and any number of groups, so a participant match could leak a - DM reply into a group thread. Return ``None`` and let the caller create - a fresh DM via ``_create_chat_for_handle``.""" + """Resolve an email/phone to a chat GUID (raw ``a;-;b`` GUIDs pass through). Matches strictly on + ``chatIdentifier`` / ``identifier``; participant membership is intentionally NOT a fallback — + the same contact appears in a 1:1 DM and any number of groups, so a participant match could + leak a DM reply into a group thread. ``None`` lets the caller create a fresh DM.""" target = (target or "").strip() if not target: return None @@ -365,8 +343,8 @@ class BlueBubblesAdapter(BasePlatformAdapter): async def _create_chat_for_handle(self, address: str, message: str) -> SendResult: """Create a new chat by sending the first message to *address*.""" - payload = {"addresses": [address], "message": message, "tempGuid": _temp_guid()} - return await self._post_message("/api/v1/chat/new", payload) + return await self._post_message( + "/api/v1/chat/new", {"addresses": [address], "message": message, "tempGuid": _temp_guid()}) # --- Text sending --- @@ -376,9 +354,8 @@ class BlueBubblesAdapter(BasePlatformAdapter): chunks = BasePlatformAdapter.truncate_message(content, max_length) return [_PAGINATION_SUFFIX_RE.sub("", c) for c in chunks] - async def send( - self, chat_id: str, content: str, reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None) -> SendResult: + async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None) -> SendResult: text = self.format_message(content) if not text: return SendResult(success=False, error="BlueBubbles send requires text") @@ -392,8 +369,7 @@ class BlueBubblesAdapter(BasePlatformAdapter): for chunk in chunks: guid = await self._resolve_chat_guid(chat_id) if not guid: - # If the target looks like an address, try creating a new chat - if self._private_api_enabled and ("@" in chat_id or _ADDRESS_RE.match(chat_id)): + if self._private_api_enabled and ("@" in chat_id or _ADDRESS_RE.match(chat_id)): # address → new chat return await self._create_chat_for_handle(chat_id, chunk) return SendResult(success=False, error=f"BlueBubbles chat not found for target: {chat_id}") payload: Dict[str, Any] = {"chatGuid": guid, "tempGuid": _temp_guid(), "message": chunk} @@ -406,9 +382,8 @@ class BlueBubblesAdapter(BasePlatformAdapter): # --- Media sending (outbound) --- - async def _send_attachment( - self, chat_id: str, file_path: str, filename: Optional[str] = None, - caption: Optional[str] = None, is_audio_message: bool = False) -> SendResult: + async def _send_attachment(self, chat_id: str, file_path: str, filename: Optional[str] = None, + caption: Optional[str] = None, is_audio_message: bool = False) -> SendResult: """Send a file attachment via BlueBubbles multipart upload.""" if not self.client: return SendResult(success=False, error="Not connected") @@ -419,30 +394,28 @@ class BlueBubblesAdapter(BasePlatformAdapter): return SendResult(success=False, error=f"Chat not found: {chat_id}") fname = filename or os.path.basename(file_path) try: - # httpx's async multipart iterator reads file objects through a sync - # chunk generator — read the bytes off the event-loop thread first. + # httpx's async multipart iterator reads file objects through a sync chunk generator — + # read the bytes off the event-loop thread first. payload = await asyncio.to_thread(Path(file_path).read_bytes) - files = {"attachment": (fname, payload, "application/octet-stream")} data: Dict[str, str] = {"chatGuid": guid, "name": fname, "tempGuid": uuid.uuid4().hex} if is_audio_message: data["isAudioMessage"] = "true" - res = await self.client.post( - self._api_url("/api/v1/message/attachment"), files=files, data=data, timeout=120) + res = await self.client.post(self._api_url("/api/v1/message/attachment"), data=data, timeout=120, + files={"attachment": (fname, payload, "application/octet-stream")}) res.raise_for_status() result = res.json() if caption: await self.send(chat_id, caption) if result.get("status") == 200: rdata = result.get("data") or {} - msg_id = rdata.get("guid") if isinstance(rdata, dict) else None - return SendResult(success=True, message_id=msg_id, raw_response=result) + return SendResult(success=True, message_id=rdata.get("guid") if isinstance(rdata, dict) else None, + raw_response=result) return SendResult(success=False, error=result.get("message", "Attachment upload failed")) except Exception as e: return SendResult(success=False, error=str(e)) - async def send_image( - self, chat_id: str, image_url: str, caption: Optional[str] = None, - reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult: + async def send_image(self, chat_id: str, image_url: str, caption: Optional[str] = None, + reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult: try: from gateway.platforms.base import cache_image_from_url local_path = await cache_image_from_url(image_url) @@ -482,14 +455,12 @@ class BlueBubblesAdapter(BasePlatformAdapter): is_group = ";+;" in (chat_id or "") info: Dict[str, Any] = {"name": chat_id, "type": "group" if is_group else "dm"} with suppress(Exception): - guid = await self._resolve_chat_guid(chat_id) - if guid: + if guid := await self._resolve_chat_guid(chat_id): res = await self._api_get(f"/api/v1/chat/{quote(guid, safe='')}?with=participants") data = (res or {}).get("data", {}) info["name"] = data.get("displayName") or data.get("chatIdentifier") or chat_id - participants = [ - addr for p in data.get("participants", []) or [] if (addr := (p.get("address") or "").strip()) - ] + participants = [addr for p in data.get("participants", []) or [] + if (addr := (p.get("address") or "").strip())] if participants: info["participants"] = participants return info @@ -504,9 +475,8 @@ class BlueBubblesAdapter(BasePlatformAdapter): if not self.client: return None try: - resp = await self.client.get( - self._api_url(f"/api/v1/attachment/{quote(att_guid, safe='')}/download"), - timeout=60, follow_redirects=True) + resp = await self.client.get(self._api_url(f"/api/v1/attachment/{quote(att_guid, safe='')}/download"), + timeout=60, follow_redirects=True) resp.raise_for_status() data = resp.content mime = (att_meta.get("mimeType") or "").lower() @@ -515,8 +485,7 @@ class BlueBubblesAdapter(BasePlatformAdapter): if mime.startswith("audio/"): return cache_audio_from_bytes(data, _closed_ext(mime, _BLUEBUBBLES_AUDIO_EXT_OVERRIDES, ".mp3")) # Videos, documents, and everything else - filename = att_meta.get("transferName", "") or f"file_{uuid.uuid4().hex[:8]}" - return cache_document_from_bytes(data, filename) + return cache_document_from_bytes(data, att_meta.get("transferName", "") or f"file_{uuid.uuid4().hex[:8]}") except Exception as exc: logger.warning("[bluebubbles] failed to download attachment %s: %s", _redact(att_guid), exc) return None @@ -527,10 +496,8 @@ class BlueBubblesAdapter(BasePlatformAdapter): data = payload.get("data") if isinstance(data, dict): return data - if isinstance(data, list): - for item in data: - if isinstance(item, dict): - return item + if isinstance(data, list) and (first := next((i for i in data if isinstance(i, dict)), None)): + return first if isinstance(payload.get("message"), dict): return payload.get("message") return payload if isinstance(payload, dict) else None @@ -557,9 +524,7 @@ class BlueBubblesAdapter(BasePlatformAdapter): msg_type = MessageType.TEXT for att in record.get("attachments") or []: att_guid = att.get("guid", "") - if not att_guid: - continue - cached = await self._download_attachment(att_guid, att) + cached = await self._download_attachment(att_guid, att) if att_guid else None if not cached: continue mime = (att.get("mimeType") or "").lower() @@ -590,12 +555,8 @@ class BlueBubblesAdapter(BasePlatformAdapter): record.get("chatIdentifier"), record.get("identifier"), payload.get("chatIdentifier"), payload.get("identifier")) handle = record.get("handle") - sender = ( - self._value( - handle.get("address") if isinstance(handle, dict) else None, - record.get("sender"), record.get("from"), record.get("address")) - or chat_identifier - or chat_guid) + sender = (self._value(handle.get("address") if isinstance(handle, dict) else None, record.get("sender"), + record.get("from"), record.get("address")) or chat_identifier or chat_guid) if not (chat_guid or chat_identifier) and sender: chat_identifier = sender return chat_guid, chat_identifier, sender @@ -611,15 +572,13 @@ class BlueBubblesAdapter(BasePlatformAdapter): logger.error("[bluebubbles] webhook parse error: %s", exc) return web.json_response({"error": "invalid payload"}, status=400) event_type = self._value(payload.get("type"), payload.get("event")) or "" - # Only process message events; silently acknowledge everything else - if event_type and event_type not in _MESSAGE_EVENTS: + if event_type and event_type not in _MESSAGE_EVENTS: # ack non-message events silently return _ok() record = self._extract_payload_record(payload) or {} if record.get("isFromMe") or record.get("fromMe") or record.get("is_from_me"): return _ok() - # Skip tapback reactions delivered as messages assoc_type = record.get("associatedMessageType") - if isinstance(assoc_type, int) and assoc_type in _TAPBACK_CODES: + if isinstance(assoc_type, int) and assoc_type in _TAPBACK_CODES: # tapback reactions delivered as messages return _ok() text = self._value(record.get("text"), record.get("message"), record.get("body")) or "" media_urls, media_types, msg_type = await self._collect_attachments(record) @@ -647,7 +606,6 @@ class BlueBubblesAdapter(BasePlatformAdapter): task = asyncio.create_task(self.handle_message(event)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) - # Fire-and-forget read receipt - if self.send_read_receipts and session_chat_id: + if self.send_read_receipts and session_chat_id: # fire-and-forget read receipt asyncio.create_task(self.mark_read(session_chat_id)) return _ok() diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index af0864eef7..5384041ebb 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -956,7 +956,8 @@ class SignalAdapter(BasePlatformAdapter): async def send_reaction(self, chat_id: str, emoji: str, target_author: str, target_timestamp: int) -> bool: """React to the message (author number/UUID, Signal ms timestamp) via signal-cli RPC.""" ok = await self._send_reaction_rpc(chat_id, { - "account": self.account, "emoji": emoji, "targetAuthor": target_author, "targetTimestamp": target_timestamp}) + "account": self.account, "emoji": emoji, "targetAuthor": target_author, + "targetTimestamp": target_timestamp}) if not ok: logger.debug("Signal: sendReaction failed (chat=%s, emoji=%s)", chat_id[:20], emoji) return ok diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 3635b5340f..2933cb1709 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -538,7 +538,8 @@ class WebhookAdapter(BasePlatformAdapter): or payload.get("event_type", "") or payload.get("type", "") or "unknown") allowed_events = route_config.get("events", []) if allowed_events and event_type not in allowed_events: - logger.debug("[webhook] Ignoring event %s for route %s (allowed: %s)", event_type, route_name, allowed_events) + logger.debug("[webhook] Ignoring event %s for route %s (allowed: %s)", event_type, route_name, + allowed_events) return web.json_response({"status": "ignored", "event": event_type}) if not self._route_processor.route_filters_match(route_config, payload, event_type, request.headers): logger.info("[webhook] filtered event=%s route=%s", event_type, route_name) @@ -550,7 +551,8 @@ class WebhookAdapter(BasePlatformAdapter): if script: # Shells out (up to its timeout) — worker thread so the loop isn't blocked; to_thread # copies contextvars so the profile scope follows. - keep, transformed_payload = await asyncio.to_thread(self._route_processor.run_route_script, script, payload) + keep, transformed_payload = await asyncio.to_thread( + self._route_processor.run_route_script, script, payload) if not keep: logger.info("[webhook] script ignored event=%s route=%s", event_type, route_name) return web.json_response({"status": "ignored", "reason": "script", "route": route_name}) @@ -658,8 +660,8 @@ class WebhookAdapter(BasePlatformAdapter): logger.warning("[webhook] Route '%s' sent X-Webhook-Signature-V2 with no X-Webhook-Timestamp — " "rejecting rather than falling back to legacy V1", route_name) return False - if not _timestamp_fresh(v2_timestamp, "[webhook] Route '%s' generic HMAC V2 timestamp outside replay window", - route_name): + if not _timestamp_fresh( + v2_timestamp, "[webhook] Route '%s' generic HMAC V2 timestamp outside replay window", route_name): return False return _hmac_str_equal(v2_sig, _hex_hmac(secret, v2_timestamp.encode() + b"." + body)) # Generic V1 (legacy, deprecated): body-only HMAC → replays indefinitely. From 2a5e5ff1ed57ca3b7670c15c5fa985dfcc09d53e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:28:19 -0700 Subject: [PATCH 04/12] =?UTF-8?q?refactor(gateway/platforms):=20msgraph=5F?= =?UTF-8?q?webhook.py=20second=20pass=20=E2=80=94=20compact=20docs,=20fold?= =?UTF-8?q?=20loops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/msgraph_webhook.py | 76 +++++++++++----------------- 1 file changed, 29 insertions(+), 47 deletions(-) diff --git a/gateway/platforms/msgraph_webhook.py b/gateway/platforms/msgraph_webhook.py index 7b4bfdc7e1..a87a303e06 100644 --- a/gateway/platforms/msgraph_webhook.py +++ b/gateway/platforms/msgraph_webhook.py @@ -26,10 +26,9 @@ from gateway.platforms.base import ( logger = logging.getLogger(__name__) -# ``None`` → aiohttp binds one socket per address family (IPv4 + IPv6); the old -# "0.0.0.0" default was unreachable over IPv6-only private networks. Pin a host -# via extra.host. The all-interfaces default still requires -# extra.allowed_source_cidrs (see _source_allowlist_required_but_missing). +# ``None`` → aiohttp binds one socket per address family (IPv4 + IPv6); the old "0.0.0.0" default was +# unreachable over IPv6-only private networks. Pin a host via extra.host. The all-interfaces default +# still requires extra.allowed_source_cidrs (see _source_allowlist_required_but_missing). DEFAULT_HOST = None DEFAULT_PORT = 8646 DEFAULT_WEBHOOK_PATH = "/msgraph/webhook" @@ -54,11 +53,9 @@ def _normalize_path(path: Any) -> str: def _parse_allowed_source_cidrs(raw: Any) -> list[ipaddress._BaseNetwork]: - """Parse the optional CIDR allowlist; empty/missing means "allow everything". - - When populated, requests from source IPs outside every listed CIDR are - rejected with 403 before the body is parsed (restrict to Microsoft - Graph's published webhook source ranges in production).""" + """Parse the optional CIDR allowlist; empty/missing means "allow everything". When populated, source + IPs outside every listed CIDR get 403 before the body is parsed (restrict to Microsoft Graph's + published webhook source ranges in production).""" if isinstance(raw, str): candidates = raw.split(",") elif isinstance(raw, (list, tuple, set)): @@ -66,8 +63,7 @@ def _parse_allowed_source_cidrs(raw: Any) -> list[ipaddress._BaseNetwork]: else: return [] networks: list[ipaddress._BaseNetwork] = [] - for chunk in candidates: - chunk = chunk.strip() + for chunk in (c.strip() for c in candidates): if not chunk: continue try: @@ -111,8 +107,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): self._webhook_path: str = _normalize_path(extra.get("webhook_path", DEFAULT_WEBHOOK_PATH)) self._health_path: str = _normalize_path(extra.get("health_path", "/health")) self._accepted_resources: list[str] = [ - str(value).strip() for value in (extra.get("accepted_resources") or []) if str(value).strip() - ] + str(value).strip() for value in (extra.get("accepted_resources") or []) if str(value).strip()] self._client_state: Optional[str] = _string_or_none(extra.get("client_state")) self._max_seen_receipts = max(1, int(extra.get("max_seen_receipts", DEFAULT_MAX_SEEN_RECEIPTS))) self._max_body_bytes = max(1, int(extra.get("max_body_bytes", DEFAULT_MAX_BODY_BYTES))) @@ -137,12 +132,9 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): logger.error("[msgraph_webhook] Refusing to start without extra.client_state configured") return False if self._source_allowlist_required_but_missing(): - logger.error( - "[msgraph_webhook] Refusing to start: binding to %s requires " - "extra.allowed_source_cidrs. Configure the Microsoft Graph " - "source CIDRs or bind to loopback (127.0.0.1/::1) behind a " - "tunnel or reverse proxy.", - self._host) + logger.error("[msgraph_webhook] Refusing to start: binding to %s requires extra.allowed_source_cidrs. " + "Configure the Microsoft Graph source CIDRs or bind to loopback (127.0.0.1/::1) behind a " + "tunnel or reverse proxy.", self._host) return False app = web.Application(client_max_size=self._max_body_bytes) app.router.add_get(self._health_path, self._handle_health) @@ -164,9 +156,8 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): self._runner = None self._mark_disconnected() - async def send( - self, chat_id: str, content: str, reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None) -> SendResult: + async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None) -> SendResult: logger.info("[msgraph_webhook] Response for %s: %s", chat_id, content[:200]) return SendResult(success=True) @@ -181,8 +172,8 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): "accepted": self._accepted_count, "duplicates": self._duplicate_count}) async def _handle_validation(self, request: "web.Request") -> "web.Response": - """Graph subscription validation handshake: echo ``validationToken`` verbatim - as text/plain. Bare GETs are rejected so the endpoint can't be enumerated.""" + """Graph subscription validation handshake: echo ``validationToken`` verbatim as text/plain. Bare GETs + are rejected so the endpoint can't be enumerated.""" if not self._source_ip_allowed(request): return web.Response(status=403) validation_token = request.query.get("validationToken", "") @@ -198,9 +189,8 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): if not self._resource_accepted(str(notification.get("resource") or "")): return "other" if not self._verify_client_state(notification): - # Bad clientState is an auth failure: a fully forged batch gets 403 - # so the sender stops retrying; legitimate Graph retries carry a - # valid clientState and hit the accepted/duplicate paths. + # Bad clientState is an auth failure: a fully forged batch gets 403 so the sender stops + # retrying; legitimate Graph retries carry a valid clientState → accepted/duplicate paths. return "auth" explicit_id = str(notification.get("id") or "").strip() receipt_key = f"id:{explicit_id}" if explicit_id else None @@ -226,9 +216,8 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): for raw_notification in notifications: counts[self._ingest_notification(raw_notification)] += 1 self._duplicate_count += counts["duplicate"] - # Anything ingested OR deduped → 202 with empty body (Graph acks; no - # counter leak). Every item failed auth → 403 so forged POSTs get a - # clear reject. Otherwise (malformed / resource not accepted) → 400. + # Anything ingested OR deduped → 202 with empty body (Graph acks; no counter leak). Every item + # failed auth → 403 so forged POSTs get a clear reject. Otherwise (malformed / not accepted) → 400. if counts["accepted"] or counts["duplicate"]: return web.Response(status=202) if counts["auth"] and not counts["other"]: @@ -254,13 +243,11 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): except (json.JSONDecodeError, UnicodeDecodeError): return 400, [] notifications = body.get("value") if isinstance(body, dict) else None - if not isinstance(notifications, list): - return 400, [] - return 0, notifications + return (0, notifications) if isinstance(notifications, list) else (400, []) def _source_ip_allowed(self, request: "web.Request") -> bool: - """Loopback-only binds may omit ``allowed_source_cidrs`` (local proxies, - dev tunnels); network-accessible binds fail closed without one.""" + """Loopback-only binds may omit ``allowed_source_cidrs`` (local proxies, dev tunnels); + network-accessible binds fail closed without one.""" if self._source_allowlist_required_but_missing(): return False if not self._allowed_source_networks: @@ -275,25 +262,21 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): if not self._accepted_resources: return True resource = resource.strip().strip("/") - for pattern in self._accepted_resources: - pattern = pattern.strip().strip("/") - if not pattern: - continue + for pattern in (p.strip().strip("/") for p in self._accepted_resources): if pattern.endswith("*"): pattern = pattern[:-1].rstrip("/") - if _prefix_match(resource, pattern): + if pattern and _prefix_match(resource, pattern): return True return False def _verify_client_state(self, notification: Dict[str, Any]) -> bool: - """Timing-safe compare of the Graph-supplied clientState against the - configured shared secret (``openssl rand -hex 32`` in the setup guide).""" + """Timing-safe compare of the Graph-supplied clientState against the configured shared secret + (``openssl rand -hex 32`` in the setup guide).""" expected = self._client_state provided = _string_or_none(notification.get("clientState")) if expected is None or provided is None: return False - # Compare as bytes: compare_digest raises TypeError on non-ASCII str, - # and clientState comes from the request body. + # Compare as bytes: compare_digest raises TypeError on non-ASCII str (clientState is request-controlled). return hmac.compare_digest(provided.encode(), expected.encode()) def _remember_receipt(self, receipt_key: str) -> None: @@ -305,9 +288,8 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): def _build_message_event(self, notification: Dict[str, Any], receipt_key: Optional[str]) -> MessageEvent: message_id = receipt_key or f"sha1:{sha1(json.dumps(notification, sort_keys=True).encode('utf-8')).hexdigest()}" source = self.build_source( - chat_id=f"msgraph:{notification.get('subscriptionId', 'unknown')}", - chat_name="msgraph/webhook", chat_type="webhook", - user_id="msgraph", user_name="Microsoft Graph") + chat_id=f"msgraph:{notification.get('subscriptionId', 'unknown')}", chat_name="msgraph/webhook", + chat_type="webhook", user_id="msgraph", user_name="Microsoft Graph") return MessageEvent( text=self._render_prompt(notification), message_type=MessageType.TEXT, source=source, raw_message=notification, message_id=message_id, internal=True) From f24cb87dc2e8418debd1a499c0ffe3e8c99c46ef Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:33:07 -0700 Subject: [PATCH 05/12] =?UTF-8?q?refactor(gateway/platforms):=20signal=5Fr?= =?UTF-8?q?ate=5Flimit.py=20second=20pass=20=E2=80=94=20compact=20docstrin?= =?UTF-8?q?gs,=20fold=20log=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/signal_rate_limit.py | 154 ++++++++++--------------- 1 file changed, 60 insertions(+), 94 deletions(-) diff --git a/gateway/platforms/signal_rate_limit.py b/gateway/platforms/signal_rate_limit.py index 202b393d94..1862d86fea 100644 --- a/gateway/platforms/signal_rate_limit.py +++ b/gateway/platforms/signal_rate_limit.py @@ -1,12 +1,8 @@ -"""Signal attachment rate-limit scheduler. - -Process-wide token-bucket simulator mirroring the per-account attachment rate limit -signal-cli/Signal-Server enforce. Producers (``SignalAdapter.send_multiple_images`` and -the ``send_message`` tool's Signal path) call ``acquire(n)`` before an attachment send; -on a 429 they call ``feedback(retry_after, n)`` so the model recalibrates from the -server's authoritative hint. Concurrent calls serialize through an ``asyncio.Lock`` -— FIFO fairness across sessions sharing one daemon. -""" +"""Signal attachment rate-limit scheduler: process-wide token-bucket simulator mirroring the per-account +attachment rate limit signal-cli/Signal-Server enforce. Producers (``SignalAdapter.send_multiple_images`` +and the ``send_message`` tool's Signal path) call ``acquire(n)`` before an attachment send; on a 429 they +call ``feedback(retry_after, n)`` so the model recalibrates from the server's authoritative hint. +Concurrent calls serialize through an ``asyncio.Lock`` — FIFO fairness across sessions sharing one daemon.""" from __future__ import annotations @@ -20,11 +16,11 @@ from agent.retry_utils import parse_retry_after_seconds logger = logging.getLogger(__name__) -SIGNAL_MAX_ATTACHMENTS_PER_MSG = 32 # per-message attachment cap (source: Signal-{Android,Desktop} source code) -SIGNAL_RATE_LIMIT_BUCKET_CAPACITY = 50 # server-side token-bucket capacity for attachments rate limiting +SIGNAL_MAX_ATTACHMENTS_PER_MSG = 32 # per-message attachment cap (Signal-{Android,Desktop} source) +SIGNAL_RATE_LIMIT_BUCKET_CAPACITY = 50 # server-side token-bucket capacity for attachments SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER = 4 # fallback token refill interval for signal-cli < v0.14.3 SIGNAL_RATE_LIMIT_MAX_ATTEMPTS = 2 # initial attempt + 1 retry -SIGNAL_BATCH_PACING_NOTICE_THRESHOLD = 10.0 # if estimated waiting time > 10s, notify the user about the delay +SIGNAL_BATCH_PACING_NOTICE_THRESHOLD = 10.0 # estimated wait above this → notify the user SIGNAL_RPC_ERROR_RATELIMIT = -5 # signal-cli (v0.14.3+) JSON-RPC error code for RateLimitException @@ -41,9 +37,8 @@ class SignalSchedulerError(Exception): pass -# "Retry after 4 seconds" — libsignal-net's RetryLaterException string form, surfaced -# when 429s hit during attachment upload (signal-cli wraps these as -# AttachmentInvalidException rather than RateLimitException, so the typed path doesn't fire). +# "Retry after 4 seconds" — libsignal-net's RetryLaterException string form, surfaced when 429s hit during +# attachment upload (signal-cli wraps these as AttachmentInvalidException, so the typed path doesn't fire). _RETRY_AFTER_RE = re.compile(r"Retry after (\d+(?:\.\d+)?)\s*second", re.IGNORECASE) @@ -52,37 +47,28 @@ def _error_message(err: Any) -> str: def _extract_retry_after_seconds(err: Any) -> Optional[float]: - """Per-token Retry-After from a signal-cli rate-limit error, or None. - - Sources, in order: ``error.data.response.results[*].retryAfterSeconds`` (signal-cli - ≥ v0.14.3), then "Retry after N seconds" parsed from the message (libsignal-net - RetryLaterException wrapped as AttachmentInvalidException, structured field null).""" + """Per-token Retry-After from a signal-cli rate-limit error, or None. Sources, in order: + ``error.data.response.results[*].retryAfterSeconds`` (signal-cli ≥ v0.14.3), then "Retry after N + seconds" parsed from the message (RetryLaterException wrapped as AttachmentInvalidException).""" if isinstance(err, dict): results = ((err.get("data") or {}).get("response") or {}).get("results") or [] - candidates = [ - parse_retry_after_seconds(r.get("retryAfterSeconds")) for r in results - if isinstance(r, dict) and r.get("retryAfterSeconds")] - candidates = [c for c in candidates if c is not None] - if candidates: + candidates = [parse_retry_after_seconds(r.get("retryAfterSeconds")) for r in results + if isinstance(r, dict) and r.get("retryAfterSeconds")] + if candidates := [c for c in candidates if c is not None]: return max(candidates) match = _RETRY_AFTER_RE.search(_error_message(err)) return parse_retry_after_seconds(match.group(1)) if match else None def _is_signal_rate_limit_error(err: Any) -> bool: - """True if a signal-cli RPC error reflects a rate-limit failure: the typed - ``RATELIMIT_ERROR`` code (≥ v0.14.3), legacy ``[429]`` / ``RateLimitException`` - substrings, or libsignal-net's ``RetryLaterException`` / "Retry after N seconds" - leaked through AttachmentInvalidException during upload (substring is the only signal).""" + """True if a signal-cli RPC error reflects a rate-limit failure: the typed ``RATELIMIT_ERROR`` code + (≥ v0.14.3), legacy ``[429]`` / ``RateLimitException`` substrings, or libsignal-net's + ``RetryLaterException`` / "Retry after N seconds" leaked through AttachmentInvalidException.""" if isinstance(err, dict) and err.get("code") == SIGNAL_RPC_ERROR_RATELIMIT: return True message = _error_message(err) msg_lower = message.lower() - return ( - "[429]" in message - or "ratelimit" in msg_lower - or "retrylaterexception" in msg_lower - or "retry after" in msg_lower) + return "[429]" in message or any(s in msg_lower for s in ("ratelimit", "retrylaterexception", "retry after")) def _format_wait(seconds: float) -> str: @@ -92,23 +78,19 @@ def _format_wait(seconds: float) -> str: def _signal_send_timeout(num_attachments: int) -> float: - """HTTP timeout for a Signal ``send`` RPC: signal-cli uploads attachments serially inside - the call, so the default 30s truncates large batches mid-upload and logs a phantom - failure even though the send completes. Scale at 5s/attachment with a 60s floor.""" + """HTTP timeout for a Signal ``send`` RPC: signal-cli uploads attachments serially inside the call, so + the default 30s truncates large batches mid-upload (phantom failure). 5s/attachment, 60s floor.""" return 30.0 if num_attachments <= 0 else max(60.0, 5.0 * num_attachments) class SignalAttachmentScheduler: - """Process-wide token-bucket simulator for Signal attachment sends. + """Process-wide token-bucket simulator for Signal attachment sends: up to ``capacity`` tokens (default + 50 = Signal's server bucket), one per attachment, refilling at ``refill_rate``/s (calibrated from the + server's per-token Retry-After once a 429 has been observed; default 1 token / 4s). ``acquire(n)`` + calls serialize through an ``asyncio.Lock`` — FIFO across sessions.""" - Holds up to ``capacity`` tokens (default 50 = Signal's server bucket); each - attachment consumes one. Tokens refill at ``refill_rate``/s, calibrated from the - server's per-token Retry-After once a 429 has been observed (default 1 token / 4s). - ``acquire(n)`` calls serialize through an ``asyncio.Lock`` — FIFO across sessions.""" - - def __init__( - self, capacity: float = float(SIGNAL_RATE_LIMIT_BUCKET_CAPACITY), - default_retry_after: float = float(SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER)) -> None: + def __init__(self, capacity: float = float(SIGNAL_RATE_LIMIT_BUCKET_CAPACITY), + default_retry_after: float = float(SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER)) -> None: self.capacity = float(capacity) self.tokens = float(capacity) self.refill_rate = 1.0 / float(default_retry_after) @@ -118,10 +100,9 @@ class SignalAttachmentScheduler: def _projected_tokens(self, now: Optional[float] = None) -> float: """Tokens the bucket would hold at ``now``, without mutating state.""" elapsed = (time.monotonic() if now is None else now) - self.last_refill - projected = self.tokens - if elapsed > 0 and projected < self.capacity: - projected = min(self.capacity, projected + elapsed * self.refill_rate) - return projected + if elapsed > 0 and self.tokens < self.capacity: + return min(self.capacity, self.tokens + elapsed * self.refill_rate) + return self.tokens def _refill(self) -> None: now = time.monotonic() @@ -129,27 +110,22 @@ class SignalAttachmentScheduler: self.last_refill = now def estimate_wait(self, n: int) -> float: - """Seconds until ``n`` tokens would be available (lock-free, informational — - decides whether to emit a pacing notice *before* a possibly-blocking ``acquire``; - races vs. concurrent acquires are benign).""" + """Seconds until ``n`` tokens would be available (lock-free, informational — decides whether to emit + a pacing notice *before* a possibly-blocking ``acquire``; races vs. concurrent acquires are benign).""" deficit = n - self._projected_tokens() return 0.0 if deficit <= 0 else deficit / self.refill_rate async def acquire(self, n: int) -> float: - """Block until at least ``n`` tokens are available; return the seconds slept. - - Does **not** deduct tokens — the bucket is a read-only model of server-side - capacity; call ``report_rpc_duration()`` after the RPC to sync. The lock is - released during ``asyncio.sleep`` so other callers interleave, and the loop - re-checks after each sleep in case the deadline was pessimistic. Signal's - server is ground truth and will 429 (→ requeue) if the model drifts.""" + """Block until at least ``n`` tokens are available; return the seconds slept. Does **not** deduct + tokens — the bucket is a read-only model of server-side capacity; call ``report_rpc_duration()`` + after the RPC to sync. The lock is released during ``asyncio.sleep`` so other callers interleave, + and the loop re-checks after each sleep in case the deadline was pessimistic. Signal's server is + ground truth and will 429 (→ requeue) if the model drifts.""" if n <= 0: return 0.0 if n > self.capacity: - raise SignalSchedulerError( - f"Signal scheduler was called requesting {n} tokens (max is {self.capacity})") - total_slept = 0.0 - first_pass = True + raise SignalSchedulerError(f"Signal scheduler was called requesting {n} tokens (max is {self.capacity})") + total_slept, first_pass = 0.0, True while True: async with self._lock: self._refill() @@ -161,53 +137,43 @@ class SignalAttachmentScheduler: deficit = n - self.tokens wait = deficit / self.refill_rate if first_pass: - logger.info( - "Signal scheduler: pausing %.1fs for %d tokens " - "(available=%.1f, deficit=%.1f, refill=%.4f/s ≈ %.1fs/token)", - wait, n, self.tokens, deficit, self.refill_rate, 1.0 / self.refill_rate) + logger.info("Signal scheduler: pausing %.1fs for %d tokens (available=%.1f, deficit=%.1f, " + "refill=%.4f/s ≈ %.1fs/token)", wait, n, self.tokens, deficit, self.refill_rate, + 1.0 / self.refill_rate) first_pass = False await asyncio.sleep(wait) total_slept += wait async def report_rpc_duration(self, rpc_duration: float, n_attachments: int) -> None: - """Deduct ``n_attachments`` tokens for a completed send RPC. - - No refill is credited for the upload window: Signal's server checks the bucket - at RPC start and resumes refill only after the response, so crediting it causes - cumulative drift that eventually triggers 429s. Advances ``last_refill``.""" + """Deduct ``n_attachments`` tokens for a completed send RPC. No refill is credited for the upload + window: Signal's server checks the bucket at RPC start and resumes refill only after the response, + so crediting it causes cumulative drift that eventually triggers 429s. Advances ``last_refill``.""" if n_attachments <= 0: return async with self._lock: - now = time.monotonic() token_before = self.tokens self.tokens = max(0.0, token_before - float(n_attachments)) - self.last_refill = now - logger.log( - logging.INFO if rpc_duration > 10 and n_attachments > 5 else logging.DEBUG, - "Signal scheduler: RPC for %d att took %.1fs — " - "tokens %.1f → %.1f (deducted=%d, no upload refill credited, refill=%.4fs⁻¹)", - n_attachments, rpc_duration, token_before, self.tokens, n_attachments, self.refill_rate) + self.last_refill = time.monotonic() + logger.log(logging.INFO if rpc_duration > 10 and n_attachments > 5 else logging.DEBUG, + "Signal scheduler: RPC for %d att took %.1fs — tokens %.1f → %.1f (deducted=%d, no upload refill " + "credited, refill=%.4fs⁻¹)", n_attachments, rpc_duration, token_before, self.tokens, n_attachments, + self.refill_rate) def feedback(self, retry_after: Optional[float], n_attempted: int) -> None: - """Apply server feedback after a 429: empty the bucket and, when ``retry_after`` - (per-token refill window) is present, calibrate ``refill_rate`` from it.""" - if retry_after and retry_after > 0: - new_rate = 1.0 / float(retry_after) - if new_rate != self.refill_rate: - logger.info("Signal scheduler: calibrating refill_rate to %.4f tokens/sec " - "(server retry_after=%.1fs per token)", new_rate, retry_after) - self.refill_rate = new_rate + """Apply server feedback after a 429: empty the bucket and, when ``retry_after`` (per-token refill + window) is present, calibrate ``refill_rate`` from it.""" + if retry_after and retry_after > 0 and (new_rate := 1.0 / float(retry_after)) != self.refill_rate: + logger.info("Signal scheduler: calibrating refill_rate to %.4f tokens/sec (server retry_after=%.1fs " + "per token)", new_rate, retry_after) + self.refill_rate = new_rate self.tokens = 0.0 self.last_refill = time.monotonic() def state(self) -> dict: """Current scheduler state for diagnostic logging (read-only, doesn't advance ``last_refill``).""" - return { - "tokens": round(self._projected_tokens(), 1), - "capacity": int(self.capacity), - "refill_rate": round(self.refill_rate, 4), - "refill_seconds_per_token": round(1.0 / self.refill_rate, 1) if self.refill_rate > 0 else float("inf"), - } + return {"tokens": round(self._projected_tokens(), 1), "capacity": int(self.capacity), + "refill_rate": round(self.refill_rate, 4), + "refill_seconds_per_token": round(1.0 / self.refill_rate, 1) if self.refill_rate > 0 else float("inf")} _scheduler: Optional[SignalAttachmentScheduler] = None From 30cc3d763c4be912b7df1b0bcbea5187229960ac Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:38:27 -0700 Subject: [PATCH 06/12] =?UTF-8?q?refactor(gateway/platforms):=20qqbot=20ch?= =?UTF-8?q?unked=5Fupload/keyboards/onboard=20second=20pass=20=E2=80=94=20?= =?UTF-8?q?fold=20layouts,=20drop=20banners,=20compact=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/qqbot/chunked_upload.py | 156 ++++++++-------------- gateway/platforms/qqbot/keyboards.py | 59 +++----- gateway/platforms/qqbot/onboard.py | 18 +-- 3 files changed, 79 insertions(+), 154 deletions(-) diff --git a/gateway/platforms/qqbot/chunked_upload.py b/gateway/platforms/qqbot/chunked_upload.py index 8b9c5c448f..87434faffa 100644 --- a/gateway/platforms/qqbot/chunked_upload.py +++ b/gateway/platforms/qqbot/chunked_upload.py @@ -20,11 +20,11 @@ from gateway.platforms.qqbot.constants import FILE_UPLOAD_TIMEOUT logger = logging.getLogger(__name__) -_BIZ_CODE_DAILY_LIMIT = 40093002 # upload_prepare: daily cumulative limit +_BIZ_CODE_DAILY_LIMIT = 40093002 # upload_prepare: daily cumulative limit _BIZ_CODE_PART_RETRYABLE = 40093001 # upload_part_finish: transient _DEFAULT_CONCURRENT_PARTS = 1 _MAX_CONCURRENT_PARTS = 10 -_PART_UPLOAD_TIMEOUT = 300.0 # 5 minutes per COS PUT +_PART_UPLOAD_TIMEOUT = 300.0 # 5 minutes per COS PUT _PART_UPLOAD_MAX_RETRIES = 2 _PART_FINISH_RETRY_INTERVAL = 1.0 _PART_FINISH_DEFAULT_TIMEOUT = 120.0 @@ -33,8 +33,6 @@ _COMPLETE_UPLOAD_MAX_RETRIES = 2 _COMPLETE_UPLOAD_BASE_DELAY = 2.0 _MD5_10M_SIZE = 10_002_432 # first N bytes used for the ``md5_10m`` hash (per QQ API spec) -# ── Exceptions ── - class _UploadError(Exception): def __init__(self, file_name: str, file_size: int, message: str) -> None: self.file_name = file_name @@ -59,18 +57,14 @@ class UploadFileTooLargeError(_UploadError): def __init__(self, file_name: str, file_size: int, limit_bytes: int = 0, message: str = "") -> None: self.limit_bytes = limit_bytes limit_str = f" ({format_size(limit_bytes)})" if limit_bytes else "" - super().__init__( - file_name, file_size, - message or f"File {file_name!r} ({format_size(file_size)}) exceeds platform limit{limit_str}", - ) + super().__init__(file_name, file_size, + message or f"File {file_name!r} ({format_size(file_size)}) exceeds platform limit{limit_str}") @property def limit_human(self) -> str: return format_size(self.limit_bytes) if self.limit_bytes else "unknown" -# ── Prepare-response shape ── - @dataclass class _PreparePart: index: int @@ -97,28 +91,20 @@ def _parse_prepare_response(raw: Dict[str, Any]) -> _PrepareResult: raw_parts = src.get("parts") or src.get("part_list") or [] if not isinstance(raw_parts, list) or not raw_parts: raise ValueError(f"upload_prepare response missing parts: {str(raw)[:200]}") - parts = [ - _PreparePart( - index=int(p.get("part_index") or p.get("index") or 0), - presigned_url=str(p.get("presigned_url") or p.get("url") or ""), - block_size=int(p.get("block_size", 0))) - for p in raw_parts - if isinstance(p, dict)] + parts = [_PreparePart(index=int(p.get("part_index") or p.get("index") or 0), + presigned_url=str(p.get("presigned_url") or p.get("url") or ""), + block_size=int(p.get("block_size", 0))) + for p in raw_parts if isinstance(p, dict)] return _PrepareResult( - upload_id=upload_id, - block_size=block_size, - parts=parts, + upload_id=upload_id, block_size=block_size, parts=parts, concurrency=int(src.get("concurrency", _DEFAULT_CONCURRENT_PARTS)) or _DEFAULT_CONCURRENT_PARTS, retry_timeout=float(src.get("retry_timeout", 0.0) or 0.0)) def _api_path(chat_type: str, target_id: str, endpoint: str) -> str: - base = "/v2/users" if chat_type == "c2c" else "/v2/groups" - return f"{base}/{target_id}/{endpoint}" + return f"{'/v2/users' if chat_type == 'c2c' else '/v2/groups'}/{target_id}/{endpoint}" -# ── Chunked upload driver ── - @dataclass class _Job: """Per-upload state shared by the part workers.""" @@ -142,9 +128,8 @@ class ChunkedUploader: circular import; must raise RuntimeError with the biz_code in the message); ``http_put`` is ``(url, data, headers) -> httpx-like response`` for COS PUTs.""" - def __init__( - self, api_request: Callable[..., Awaitable[Dict[str, Any]]], http_put: Callable[..., Awaitable[Any]], - log_tag: str = "QQBot") -> None: + def __init__(self, api_request: Callable[..., Awaitable[Dict[str, Any]]], + http_put: Callable[..., Awaitable[Any]], log_tag: str = "QQBot") -> None: self._api_request = api_request self._http_put = http_put self._log_tag = log_tag @@ -158,24 +143,18 @@ class ChunkedUploader: Raises UploadDailyLimitExceededError (40093002), UploadFileTooLargeError, RuntimeError.""" if chat_type not in {"c2c", "group"}: raise ValueError(f"ChunkedUploader: unsupported chat_type {chat_type!r}") - job = _Job(chat_type, target_id, file_path, Path(file_path).stat().st_size) - logger.info( - "[%s] Chunked upload start: file=%s size=%s type=%d", - self._log_tag, file_name, format_size(job.file_size), file_type) - + logger.info("[%s] Chunked upload start: file=%s size=%s type=%d", self._log_tag, file_name, + format_size(job.file_size), file_type) # Hashing is blocking I/O → executor. hashes = await asyncio.get_running_loop().run_in_executor(None, _compute_file_hashes, file_path, job.file_size) prepare = await self._prepare(job, file_type, file_name, hashes) max_concurrent = min(prepare.concurrency, _MAX_CONCURRENT_PARTS) job.upload_id, job.block_size, job.total_parts = prepare.upload_id, prepare.block_size, len(prepare.parts) - job.retry_timeout = min( - prepare.retry_timeout if prepare.retry_timeout > 0 else _PART_FINISH_DEFAULT_TIMEOUT, - _PART_FINISH_MAX_TIMEOUT) - logger.info( - "[%s] Prepared: upload_id=%s block_size=%s parts=%d concurrency=%d", - self._log_tag, job.upload_id, format_size(job.block_size), job.total_parts, max_concurrent) - + job.retry_timeout = min(prepare.retry_timeout if prepare.retry_timeout > 0 else _PART_FINISH_DEFAULT_TIMEOUT, + _PART_FINISH_MAX_TIMEOUT) + logger.info("[%s] Prepared: upload_id=%s block_size=%s parts=%d concurrency=%d", self._log_tag, + job.upload_id, format_size(job.block_size), job.total_parts, max_concurrent) sem = asyncio.Semaphore(max(max_concurrent, 1)) async def _run(part: _PreparePart) -> None: @@ -187,42 +166,34 @@ class ChunkedUploader: return await self._complete(job) async def _prepare(self, job: _Job, file_type: int, file_name: str, hashes: Dict[str, str]) -> _PrepareResult: - body = { - "file_type": file_type, "file_name": file_name, "file_size": job.file_size, - "md5": hashes["md5"], "sha1": hashes["sha1"], "md5_10m": hashes["md5_10m"]} + body = {"file_type": file_type, "file_name": file_name, "file_size": job.file_size, + "md5": hashes["md5"], "sha1": hashes["sha1"], "md5_10m": hashes["md5_10m"]} try: raw = await self._post(job, "upload_prepare", body) except RuntimeError as exc: - err_msg = str(exc) - if f"{_BIZ_CODE_DAILY_LIMIT}" in err_msg: - raise UploadDailyLimitExceededError(file_name, job.file_size, err_msg) from exc + if f"{_BIZ_CODE_DAILY_LIMIT}" in str(exc): + raise UploadDailyLimitExceededError(file_name, job.file_size, str(exc)) from exc raise return _parse_prepare_response(raw) async def _upload_one_part(self, job: _Job, part: _PreparePart) -> None: """PUT one part to COS, then call ``upload_part_finish``.""" part_index, total_parts = part.index, job.total_parts - # Per-part block_size wins; fall back to the response-level value. - actual_block_size = part.block_size if part.block_size > 0 else job.block_size offset = (part_index - 1) * job.block_size - length = min(actual_block_size, job.file_size - offset) - + # Per-part block_size wins; fall back to the response-level value. + length = min(part.block_size if part.block_size > 0 else job.block_size, job.file_size - offset) data = await asyncio.get_running_loop().run_in_executor(None, _read_file_chunk, job.file_path, offset, length) md5_hex = hashlib.md5(data).hexdigest() - logger.debug( - "[%s] Part %d/%d: uploading %s (offset=%d md5=%s)", - self._log_tag, part_index, total_parts, format_size(length), offset, md5_hex) - + logger.debug("[%s] Part %d/%d: uploading %s (offset=%d md5=%s)", self._log_tag, part_index, total_parts, + format_size(length), offset, md5_hex) await self._put_to_presigned_url(part.presigned_url, data, part_index, total_parts) await self._part_finish_with_retry(job, part_index, length, md5_hex) - job.completed += 1 - logger.debug( - "[%s] Part %d/%d done (%d/%d total)", self._log_tag, part_index, total_parts, job.completed, total_parts) + logger.debug("[%s] Part %d/%d done (%d/%d total)", self._log_tag, part_index, total_parts, job.completed, + total_parts) - async def _with_retries( - self, attempt_fn: Callable[[], Awaitable[Any]], *, max_retries: int, base_delay: float, label: str, - failure_label: str) -> Any: + async def _with_retries(self, attempt_fn: Callable[[], Awaitable[Any]], *, max_retries: int, base_delay: float, + label: str, failure_label: str) -> Any: """Run *attempt_fn* up to ``max_retries + 1`` times with exponential backoff.""" last_exc: Exception | None = None for attempt in range(max_retries + 1): @@ -232,9 +203,8 @@ class ChunkedUploader: last_exc = exc if attempt < max_retries: delay = base_delay * (2 ** attempt) - logger.warning( - "[%s] %s attempt %d failed, retry in %.1fs: %s", - self._log_tag, label, attempt + 1, delay, exc) + logger.warning("[%s] %s attempt %d failed, retry in %.1fs: %s", self._log_tag, label, attempt + 1, + delay, exc) await asyncio.sleep(delay) raise RuntimeError(f"{failure_label} failed after {max_retries + 1} attempts: {last_exc}") @@ -242,9 +212,8 @@ class ChunkedUploader: """PUT part data to a pre-signed COS URL with retry.""" async def _attempt() -> None: - resp = await asyncio.wait_for( - self._http_put(url, data=data, headers={"Content-Length": str(len(data))}), - timeout=_PART_UPLOAD_TIMEOUT) + resp = await asyncio.wait_for(self._http_put(url, data=data, headers={"Content-Length": str(len(data))}), + timeout=_PART_UPLOAD_TIMEOUT) status = getattr(resp, "status_code", 0) if 200 <= status < 300: logger.debug("[%s] PUT part %d/%d: %d OK", self._log_tag, part_index, total_parts, status) @@ -255,16 +224,15 @@ class ChunkedUploader: body_preview = "" raise RuntimeError(f"COS PUT returned {status}: {body_preview}") - await self._with_retries( - _attempt, max_retries=_PART_UPLOAD_MAX_RETRIES, base_delay=1.0, - label=f"PUT part {part_index}/{total_parts}", failure_label=f"Part {part_index}/{total_parts} upload") + await self._with_retries(_attempt, max_retries=_PART_UPLOAD_MAX_RETRIES, base_delay=1.0, + label=f"PUT part {part_index}/{total_parts}", + failure_label=f"Part {part_index}/{total_parts} upload") async def _part_finish_with_retry(self, job: _Job, part_index: int, block_size: int, md5: str) -> None: """Call ``upload_part_finish``, retrying on biz_code 40093001 until ``job.retry_timeout``.""" body = {"upload_id": job.upload_id, "part_index": part_index, "block_size": block_size, "md5": md5} loop = asyncio.get_running_loop() - start = loop.time() - attempt = 0 + start, attempt = loop.time(), 0 while True: try: await self._post(job, "upload_part_finish", body) @@ -274,26 +242,21 @@ class ChunkedUploader: raise elapsed = loop.time() - start if elapsed >= job.retry_timeout: - raise RuntimeError( - f"upload_part_finish persistent retry timed out " - f"after {job.retry_timeout:.0f}s ({attempt} retries): {exc}") from exc + raise RuntimeError(f"upload_part_finish persistent retry timed out after " + f"{job.retry_timeout:.0f}s ({attempt} retries): {exc}") from exc attempt += 1 - logger.debug( - "[%s] part_finish retryable error, attempt %d, elapsed=%.1fs: %s", - self._log_tag, attempt, elapsed, exc) + logger.debug("[%s] part_finish retryable error, attempt %d, elapsed=%.1fs: %s", self._log_tag, attempt, + elapsed, exc) await asyncio.sleep(_PART_FINISH_RETRY_INTERVAL) async def _complete(self, job: _Job) -> Dict[str, Any]: - """Call ``complete_upload`` with retry — the ``/files`` endpoint (same as the simple - URL upload) selects the chunked-completion path when only ``upload_id`` is sent.""" + """Call ``complete_upload`` with retry — the ``/files`` endpoint (same as the simple URL upload) + selects the chunked-completion path when only ``upload_id`` is sent.""" return await self._with_retries( - lambda: self._post(job, "files", {"upload_id": job.upload_id}), - max_retries=_COMPLETE_UPLOAD_MAX_RETRIES, base_delay=_COMPLETE_UPLOAD_BASE_DELAY, - label="complete_upload", failure_label="complete_upload") + lambda: self._post(job, "files", {"upload_id": job.upload_id}), max_retries=_COMPLETE_UPLOAD_MAX_RETRIES, + base_delay=_COMPLETE_UPLOAD_BASE_DELAY, label="complete_upload", failure_label="complete_upload") -# ── Helpers (module-level for testability) ── - def format_size(size_bytes: int) -> str: """Return a human-readable file size string (e.g. ``'12.3 MB'``).""" size = float(size_bytes) @@ -309,32 +272,23 @@ def _read_file_chunk(file_path: str, offset: int, length: int) -> bytes: with open(file_path, "rb") as fh: fh.seek(offset) data = fh.read(length) - if len(data) != length: - raise IOError( - f"Short read from {file_path}: expected {length} bytes at " - f"offset {offset}, got {len(data)} (file may be truncated)") - return data + if len(data) != length: + raise IOError(f"Short read from {file_path}: expected {length} bytes at offset {offset}, got {len(data)} " + f"(file may be truncated)") + return data def _compute_file_hashes(file_path: str, file_size: int) -> Dict[str, str]: - """Compute md5, sha1, and md5_10m in a single pass.""" - md5 = hashlib.md5() - sha1 = hashlib.sha1() - md5_10m = hashlib.md5() + """Compute md5, sha1, and md5_10m in a single pass (for small files md5_10m is just the full md5).""" + md5, sha1, md5_10m = hashlib.md5(), hashlib.sha1(), hashlib.md5() need_10m = file_size > _MD5_10M_SIZE bytes_read = 0 with open(file_path, "rb") as fh: while chunk := fh.read(65536): md5.update(chunk) sha1.update(chunk) - if need_10m: - remaining = _MD5_10M_SIZE - bytes_read - if remaining > 0: - md5_10m.update(chunk[:remaining]) + if need_10m and (remaining := _MD5_10M_SIZE - bytes_read) > 0: + md5_10m.update(chunk[:remaining]) bytes_read += len(chunk) full_md5 = md5.hexdigest() - return { - "md5": full_md5, - "sha1": sha1.hexdigest(), - # For small files the "10m" hash is just the full md5. - "md5_10m": md5_10m.hexdigest() if need_10m else full_md5} + return {"md5": full_md5, "sha1": sha1.hexdigest(), "md5_10m": md5_10m.hexdigest() if need_10m else full_md5} diff --git a/gateway/platforms/qqbot/keyboards.py b/gateway/platforms/qqbot/keyboards.py index be608cb9b0..a6f205ce9d 100644 --- a/gateway/platforms/qqbot/keyboards.py +++ b/gateway/platforms/qqbot/keyboards.py @@ -1,11 +1,8 @@ -"""QQ Bot inline keyboards + approval / update-prompt helpers. - -A button click dispatches an ``INTERACTION_CREATE`` event carrying the button's -``data``; the bot must ACK promptly via ``PUT /interactions/{id}`` or the user -sees an error indicator. ``button_data`` formats: ``approve::`` -(decision = allow-once|allow-always|deny) and ``update_prompt:`` (y|n). -Ported from WideLee's qqbot-agent-sdk v1.2.2 (authorship via Co-authored-by). -""" +"""QQ Bot inline keyboards + approval / update-prompt helpers. A button click dispatches an +``INTERACTION_CREATE`` event carrying the button's ``data``; the bot must ACK promptly via +``PUT /interactions/{id}`` or the user sees an error indicator. ``button_data`` formats: +``approve::`` (allow-once|allow-always|deny) and ``update_prompt:`` (y|n). +Ported from WideLee's qqbot-agent-sdk v1.2.2 (authorship via Co-authored-by).""" from __future__ import annotations @@ -16,13 +13,10 @@ from typing import Any, Dict, List, Optional APPROVAL_BUTTON_PREFIX = "approve:" UPDATE_PROMPT_PREFIX = "update_prompt:" -# session_key may itself contain colons (e.g. agent:main:qqbot:c2c:OPENID), -# so the session_key group is greedy but trails the decision. +# session_key may itself contain colons (agent:main:qqbot:c2c:OPENID): greedy group, decision trails. _APPROVAL_DATA_RE = re.compile(r"^approve:(.+):(allow-once|allow-always|deny)$") _UPDATE_PROMPT_RE = re.compile(r"^update_prompt:(y|n)$") -# ── Keyboard dataclasses ── - def _to_dict(value: Any) -> Any: """Serialize a dataclass tree in field-declaration order (the wire shape).""" if is_dataclass(value): @@ -31,8 +25,7 @@ def _to_dict(value: Any) -> Any: class _Serializable: - def to_dict(self) -> Dict[str, Any]: - return _to_dict(self) + to_dict = _to_dict @dataclass @@ -84,8 +77,6 @@ class InlineKeyboard(_Serializable): content: KeyboardContent = field(default_factory=KeyboardContent) -# ── INTERACTION_CREATE parsing ── - def parse_approval_button_data(button_data: str) -> Optional[tuple[str, str]]: """Parse approval ``button_data`` into ``(session_key, decision)`` or ``None``.""" m = _APPROVAL_DATA_RE.match(button_data or "") @@ -98,22 +89,18 @@ def parse_update_prompt_button_data(button_data: str) -> Optional[str]: return m.group(1) if m else None -# ── Keyboard builders ── - def _single_row_keyboard(group_id: str, *buttons: tuple) -> InlineKeyboard: """One row of callback buttons from ``(id, label, visited_label, data, style)`` tuples.""" row = KeyboardRow(buttons=[ - KeyboardButton( - id=btn_id, group_id=group_id, action=KeyboardButtonAction(type=1, data=data), - render_data=KeyboardButtonRenderData(label=label, visited_label=visited, style=style)) + KeyboardButton(id=btn_id, group_id=group_id, action=KeyboardButtonAction(type=1, data=data), + render_data=KeyboardButtonRenderData(label=label, visited_label=visited, style=style)) for btn_id, label, visited, data, style in buttons]) return InlineKeyboard(content=KeyboardContent(rows=[row])) def build_approval_keyboard(session_key: str, *, allow_permanent: bool = True) -> InlineKeyboard: - """Build ``[✅ 允许一次] [⭐ 始终允许] [❌ 拒绝]`` (one group, so a click greys the rest). - ⭐ is hidden when persistent scope is unavailable; *session_key* is embedded in - ``button_data`` so the decision routes to the right approval.""" + """Build ``[✅ 允许一次] [⭐ 始终允许] [❌ 拒绝]`` (one group, so a click greys the rest). ⭐ is hidden when + persistent scope is unavailable; *session_key* rides in ``button_data`` so the decision routes correctly.""" prefix = f"{APPROVAL_BUTTON_PREFIX}{session_key}" buttons = [("allow", "✅ 允许一次", "已允许", f"{prefix}:allow-once", 1)] if allow_permanent: @@ -124,18 +111,14 @@ def build_approval_keyboard(session_key: str, *, allow_permanent: bool = True) - def build_update_prompt_keyboard() -> InlineKeyboard: """Build a Yes/No keyboard for update confirmation prompts.""" - return _single_row_keyboard( - "update_prompt", - ("yes", "✓ 确认", "已确认", f"{UPDATE_PROMPT_PREFIX}y", 1), - ("no", "✗ 取消", "已取消", f"{UPDATE_PROMPT_PREFIX}n", 0)) + return _single_row_keyboard("update_prompt", ("yes", "✓ 确认", "已确认", f"{UPDATE_PROMPT_PREFIX}y", 1), + ("no", "✗ 取消", "已取消", f"{UPDATE_PROMPT_PREFIX}n", 0)) -# ── ApprovalRequest + text builder ── - @dataclass class ApprovalRequest: - """Approval-request display data. ``command_preview`` / ``cwd`` are set for exec - approvals, ``tool_name`` for plugin approvals; ``severity`` is ``'critical' | 'info' | ''``.""" + """Approval-request display data. ``command_preview`` / ``cwd`` are set for exec approvals, ``tool_name`` + for plugin approvals; ``severity`` is ``'critical' | 'info' | ''``.""" session_key: str title: str description: str = "" @@ -172,8 +155,6 @@ def build_approval_text(req: ApprovalRequest) -> str: return "\n".join(lines) -# ── INTERACTION_CREATE event shape ── - @dataclass class InteractionEvent: """Parsed ``INTERACTION_CREATE`` payload (api-v2 event-emit docs).""" @@ -207,9 +188,7 @@ def parse_interaction_event(raw: Dict[str, Any]) -> InteractionEvent: return InteractionEvent( id=str(raw.get("id", "")), type=int(data_raw.get("type", 0) or 0), chat_type=scene_code, scene=_SCENE_NAMES.get(scene_code, ""), group_openid=str(raw.get("group_openid", "")), - group_member_openid=str(raw.get("group_member_openid", "")), - user_openid=str(raw.get("user_openid", "")), channel_id=str(raw.get("channel_id", "")), - guild_id=str(raw.get("guild_id", "")), button_data=str(resolved.get("button_data", "")), - button_id=str(resolved.get("button_id", "")), - resolver_user_id=str(resolved.get("user_id", "")), - ) + group_member_openid=str(raw.get("group_member_openid", "")), user_openid=str(raw.get("user_openid", "")), + channel_id=str(raw.get("channel_id", "")), guild_id=str(raw.get("guild_id", "")), + button_data=str(resolved.get("button_data", "")), button_id=str(resolved.get("button_id", "")), + resolver_user_id=str(resolved.get("user_id", ""))) diff --git a/gateway/platforms/qqbot/onboard.py b/gateway/platforms/qqbot/onboard.py index 0215d41b0b..44a0b917b5 100644 --- a/gateway/platforms/qqbot/onboard.py +++ b/gateway/platforms/qqbot/onboard.py @@ -1,6 +1,5 @@ -"""QQBot scan-to-configure (QR code onboard). Mirrors the Feishu pattern: synchronous -HTTP + one entry-point ``qr_register()`` (create task → display QR → poll → decrypt -credentials) against ``q.qq.com`` ``create_bind_task`` / ``poll_bind_result``.""" +"""QQBot scan-to-configure (QR code onboard). Mirrors the Feishu pattern: synchronous HTTP + one entry-point +``qr_register()`` (create task → display QR → poll → decrypt) against ``q.qq.com`` bind-task endpoints.""" from __future__ import annotations @@ -11,8 +10,7 @@ from typing import Optional, Tuple from urllib.parse import quote from .constants import ( - ONBOARD_API_TIMEOUT, ONBOARD_CREATE_PATH, ONBOARD_POLL_INTERVAL, ONBOARD_POLL_PATH, PORTAL_HOST, QR_URL_TEMPLATE, -) + ONBOARD_API_TIMEOUT, ONBOARD_CREATE_PATH, ONBOARD_POLL_INTERVAL, ONBOARD_POLL_PATH, PORTAL_HOST, QR_URL_TEMPLATE) from .crypto import decrypt_secret, generate_bind_key from .utils import get_api_headers @@ -71,7 +69,8 @@ def _create_bind_task(timeout: float = ONBOARD_API_TIMEOUT) -> Tuple[str, str]: def _poll_bind_result(task_id: str, timeout: float = ONBOARD_API_TIMEOUT) -> Tuple[BindStatus, str, str, str]: """Poll *task_id*; returns ``(status, bot_appid, bot_encrypt_secret, user_openid)``.""" d = _portal_post(ONBOARD_POLL_PATH, {"task_id": task_id}, timeout, "poll_bind_result failed").get("data", {}) - return BindStatus(d.get("status", 0)), str(d.get("bot_appid", "")), d.get("bot_encrypt_secret", ""), d.get("user_openid", "") + return (BindStatus(d.get("status", 0)), str(d.get("bot_appid", "")), d.get("bot_encrypt_secret", ""), + d.get("user_openid", "")) def build_connect_url(task_id: str) -> str: @@ -86,14 +85,12 @@ def qr_register(timeout_seconds: int = 600) -> Optional[dict]: """Run the QR registration flow; returns ``{"app_id", "client_secret", "user_openid"}`` or None on failure / expiry / cancellation. Unexpected errors propagate.""" deadline = time.monotonic() + timeout_seconds - for refresh_count in range(_MAX_REFRESHES + 1): try: task_id, aes_key = _create_bind_task() except Exception as exc: logger.warning("[QQBot onboard] Failed to create bind task: %s", exc) return None - url = build_connect_url(task_id) print() if _render_qr(url): @@ -102,14 +99,12 @@ def qr_register(timeout_seconds: int = 600) -> Optional[dict]: print(f" Open this URL in QQ on your phone:\n {url}") print(" Tip: pip install qrcode to display a scannable QR code here") print() - while time.monotonic() < deadline: try: status, app_id, encrypted_secret, user_openid = _poll_bind_result(task_id) except Exception: time.sleep(ONBOARD_POLL_INTERVAL) continue - if status == BindStatus.COMPLETED: client_secret = decrypt_secret(encrypted_secret, aes_key) print() @@ -117,17 +112,14 @@ def qr_register(timeout_seconds: int = 600) -> Optional[dict]: if user_openid: print(f" Scanner's OpenID: {user_openid}") return {"app_id": app_id, "client_secret": client_secret, "user_openid": user_openid} - if status == BindStatus.EXPIRED: if refresh_count >= _MAX_REFRESHES: logger.warning("[QQBot onboard] QR code expired %d times — giving up", _MAX_REFRESHES) return None print(f"\n QR code expired, refreshing... ({refresh_count + 1}/{_MAX_REFRESHES})") break # next for-loop iteration creates a new task - time.sleep(ONBOARD_POLL_INTERVAL) else: logger.warning("[QQBot onboard] Poll timed out after %ds", timeout_seconds) return None - return None From 04525669618bdc74ab5eddf1c8e86ae909eff24c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:47:05 -0700 Subject: [PATCH 07/12] =?UTF-8?q?refactor(gateway/platforms):=20signal.py/?= =?UTF-8?q?webhook.py=20=E2=80=94=20fold=20multi-line=20messages=20and=20c?= =?UTF-8?q?alls,=20walrus=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/signal.py | 35 ++++++------- gateway/platforms/webhook.py | 98 +++++++++++++++--------------------- 2 files changed, 55 insertions(+), 78 deletions(-) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 5384041ebb..ddd1c01e05 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -109,10 +109,8 @@ def _remux_aac_to_m4a(aac_data: bytes) -> Optional[Tuple[bytes, str]]: src_path = src.name dst_path = src_path[:-4] + ".m4a" try: - proc = subprocess.run( - [ffmpeg, "-y", "-loglevel", "error", "-i", src_path, - "-c:a", "copy", "-movflags", "+faststart", dst_path], - capture_output=True, timeout=10) + proc = subprocess.run([ffmpeg, "-y", "-loglevel", "error", "-i", src_path, "-c:a", "copy", "-movflags", + "+faststart", dst_path], capture_output=True, timeout=10) if proc.returncode != 0: logger.warning("Signal: AAC→M4A remux failed (ffmpeg exit %d): %s", proc.returncode, proc.stderr.decode("utf-8", "replace")[:300]) @@ -289,10 +287,7 @@ class SignalAdapter(BasePlatformAdapter): """Dispatch one SSE line; keepalive comments (":") count as activity so the health monitor stays quiet.""" if line.startswith(":"): self._last_sse_activity = time.time() - elif line.startswith("data:"): - data_str = line[5:].strip() - if not data_str: - return + elif line.startswith("data:") and (data_str := line[5:].strip()): self._last_sse_activity = time.time() try: await self._handle_envelope(json.loads(data_str)) @@ -458,8 +453,7 @@ class SignalAdapter(BasePlatformAdapter): return chat_id = f"group:{group_id}" if is_group else sender text = data_message.get("message", "") - mentions = data_message.get("mentions", []) - if text and mentions: + if text and (mentions := data_message.get("mentions", [])): text = _render_mentions(text, mentions) if is_group: mentioned, text = self._apply_group_mention_rules(text, data_message) @@ -474,19 +468,19 @@ class SignalAdapter(BasePlatformAdapter): media_urls, media_types = [], [] if attachments_data and not getattr(self, "ignore_attachments", False): media_urls, media_types = await self._collect_attachments(attachments_data) - # Skip contentless envelopes (profile key updates, empty messages) that still - # carry a dataMessage wrapper — otherwise msg='' triggers a full agent turn. + # Skip contentless envelopes (profile key updates, empty messages) that still carry a + # dataMessage wrapper — otherwise msg='' triggers a full agent turn. if (not text or not text.strip()) and not media_urls: - logger.debug("Signal: skipping contentless envelope from %s (%d attachments)", - redact_phone(sender), len(media_urls) if media_urls else 0) + logger.debug("Signal: skipping contentless envelope from %s (%d attachments)", redact_phone(sender), + len(media_urls) if media_urls else 0) return source = self.build_source( chat_id=chat_id, chat_name=group_info.get("groupName") if group_info else sender_name, chat_type="group" if is_group else "dm", user_id=sender, user_name=sender_name or sender, user_id_alt=sender_uuid if sender_uuid else None, chat_id_alt=group_id if is_group else None) - # First matching MIME prefix wins; everything else (application/*, text/*, unknown) - # is a DOCUMENT so run.py's document-context injection surfaces the cached path. + # First matching MIME prefix wins; everything else (application/*, text/*, unknown) is a DOCUMENT + # so run.py's document-context injection surfaces the cached path. msg_type = MessageType.TEXT if not media_types else next( (mt for prefix, mt in _MEDIA_TYPE_BY_MIME_PREFIX if any(m.startswith(prefix) for m in media_types)), MessageType.DOCUMENT) @@ -901,8 +895,8 @@ class SignalAdapter(BasePlatformAdapter): async def _send_file(self, chat_id: str, file_path: str, caption: Optional[str], fail_error: str) -> SendResult: """Send one local file as a Signal attachment via the ``send`` RPC.""" - params = {"account": self.account, "message": caption or "", "attachments": [file_path]} - await self._with_target(params, chat_id) + params = await self._with_target( + {"account": self.account, "message": caption or "", "attachments": [file_path]}, chat_id) _, err = await self._rpc_send(params, fail_error) return err or SendResult(success=True) @@ -971,9 +965,8 @@ class SignalAdapter(BasePlatformAdapter): def _extract_reaction_target(self, event: MessageEvent) -> Optional[tuple]: """Extract (target_author, target_timestamp) from a MessageEvent, or None.""" raw = event.raw_message - if isinstance(raw, dict) and raw.get("sender") and raw.get("timestamp_ms"): - return (raw["sender"], raw["timestamp_ms"]) - return None + ok = isinstance(raw, dict) and raw.get("sender") and raw.get("timestamp_ms") + return (raw["sender"], raw["timestamp_ms"]) if ok else None def _reactions_enabled(self, event: "MessageEvent" = None) -> bool: """SIGNAL_REACTIONS env gate, then the DM allowlist: reactions fire before run.py's auth gate, diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 2933cb1709..e8fa8a0566 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -187,23 +187,17 @@ class WebhookAdapter(BasePlatformAdapter): """Startup validation: secret required; INSECURE_NO_AUTH only on loopback (crash early on a public footgun).""" secret = route.get("secret", self._global_secret) if not secret: - raise ValueError( - f"[webhook] Route '{name}' has no HMAC secret. " - f"Set 'secret' on the route or globally. " - f"For testing without auth, set secret to '{_INSECURE_NO_AUTH}'.") + raise ValueError(f"[webhook] Route '{name}' has no HMAC secret. Set 'secret' on the route or globally. " + f"For testing without auth, set secret to '{_INSECURE_NO_AUTH}'.") if secret == _INSECURE_NO_AUTH and not _is_loopback_host(self._host): - raise ValueError( - f"[webhook] Route '{name}' uses INSECURE_NO_AUTH secret " - f"but is bound to non-loopback host '{self._host}'. " - f"INSECURE_NO_AUTH is for local testing only. " - f"Refusing to start to prevent accidental exposure.") + raise ValueError(f"[webhook] Route '{name}' uses INSECURE_NO_AUTH secret but is bound to non-loopback " + f"host '{self._host}'. INSECURE_NO_AUTH is for local testing only. " + f"Refusing to start to prevent accidental exposure.") if route.get("deliver_only"): deliver = route.get("deliver", "log") if not deliver or deliver == "log": - raise ValueError( - f"[webhook] Route '{name}' has deliver_only=true but " - f"deliver is '{deliver}'. Direct delivery requires a " - f"real target (telegram, discord, slack, github_comment, etc.).") + raise ValueError(f"[webhook] Route '{name}' has deliver_only=true but deliver is '{deliver}'. Direct " + f"delivery requires a real target (telegram, discord, slack, github_comment, etc.).") async def connect(self, *, is_reconnect: bool = False) -> bool: self._reload_dynamic_routes() @@ -228,15 +222,12 @@ class WebhookAdapter(BasePlatformAdapter): except OSError as exc: await self._runner.cleanup() self._runner = None - logger.error( - "[webhook] Could not bind %s:%d: %s. " - "Set a different host or port in config.yaml under platforms.webhook.extra.", - self._host or "all IPv4+IPv6 interfaces", self._port, exc) + logger.error("[webhook] Could not bind %s:%d: %s. Set a different host or port in config.yaml under " + "platforms.webhook.extra.", self._host or "all IPv4+IPv6 interfaces", self._port, exc) return False self._mark_connected() - route_names = ", ".join(self._routes.keys()) or "(none configured)" - logger.info("[webhook] Listening on %s:%d — routes: %s", - self._host or "* (all interfaces, IPv4+IPv6)", self._port, route_names) + logger.info("[webhook] Listening on %s:%d — routes: %s", self._host or "* (all interfaces, IPv4+IPv6)", + self._port, ", ".join(self._routes.keys()) or "(none configured)") self._wire_plugin_handlers(None) return True @@ -272,12 +263,13 @@ class WebhookAdapter(BasePlatformAdapter): def _prune_delivery_info(self, now: float) -> None: """Drop delivery_info entries older than the idempotency TTL (bounds the dict by ``rate_limit * TTL`` even when runs never produce a final response).""" - created = self._delivery_info_created - if len(self._delivery_info_order) < len(created): - self._delivery_info_order = deque((at, key) for key, at in sorted(created.items(), key=lambda kv: kv[1])) + created, order = self._delivery_info_created, self._delivery_info_order + if len(order) < len(created): + order = self._delivery_info_order = deque( + (at, key) for key, at in sorted(created.items(), key=lambda kv: kv[1])) cutoff = now - self._idempotency_ttl - while self._delivery_info_order and self._delivery_info_order[0][0] < cutoff: - created_at, key = self._delivery_info_order.popleft() + while order and order[0][0] < cutoff: + created_at, key = order.popleft() if created.get(key) == created_at: self._delivery_info.pop(key, None) created.pop(key, None) @@ -358,8 +350,7 @@ class WebhookAdapter(BasePlatformAdapter): subs_path = get_hermes_home() / _DYNAMIC_ROUTES_FILENAME if not subs_path.exists(): if self._dynamic_routes: - self._dynamic_routes = {} - self._routes = dict(self._static_routes) + self._dynamic_routes, self._routes = {}, dict(self._static_routes) logger.debug("[webhook] Dynamic subscriptions file removed, cleared dynamic routes") return try: @@ -373,8 +364,8 @@ class WebhookAdapter(BasePlatformAdapter): k: v for k, v in data.items() if k not in self._static_routes and self._dynamic_route_allowed(k, v)} self._routes = {**self._dynamic_routes, **self._static_routes} self._dynamic_routes_mtime = mtime - logger.info("[webhook] Reloaded %d dynamic route(s): %s", - len(self._dynamic_routes), ", ".join(self._dynamic_routes.keys()) or "(none)") + logger.info("[webhook] Reloaded %d dynamic route(s): %s", len(self._dynamic_routes), + ", ".join(self._dynamic_routes.keys()) or "(none)") except Exception as e: logger.error("[webhook] Failed to reload dynamic routes: %s", e) @@ -405,10 +396,10 @@ class WebhookAdapter(BasePlatformAdapter): @staticmethod def _route_allows_profile(route_config: dict, request_profile: Optional[str]) -> bool: """Omitting ``profile`` binds a route to default; an explicit null/blank/non-string fails closed.""" - configured_profile = route_config.get("profile") if "profile" in route_config else "default" - if not isinstance(configured_profile, str) or not configured_profile.strip(): + configured = route_config.get("profile") if "profile" in route_config else "default" + if not isinstance(configured, str) or not configured.strip(): return False - return configured_profile.strip() == (request_profile or "default") + return configured.strip() == (request_profile or "default") @staticmethod def _profile_scope(profile: Optional[str]): @@ -460,12 +451,10 @@ class WebhookAdapter(BasePlatformAdapter): event_type: str, delivery_id: str) -> "web.Response": """deliver_only: the rendered prompt IS the message — skip the agent, reuse the same auth/rate-limit/idempotency/template pipeline.""" - delivery = { - "deliver": route_config.get("deliver", "log"), - "deliver_extra": self._render_delivery_extra(route_config.get("deliver_extra", {}), payload), - "payload": payload} - logger.info("[webhook] direct-deliver event=%s route=%s target=%s msg_len=%d delivery=%s", - event_type, route_name, delivery["deliver"], len(prompt), delivery_id) + delivery = {"deliver": route_config.get("deliver", "log"), "payload": payload, + "deliver_extra": self._render_delivery_extra(route_config.get("deliver_extra", {}), payload)} + logger.info("[webhook] direct-deliver event=%s route=%s target=%s msg_len=%d delivery=%s", event_type, + route_name, delivery["deliver"], len(prompt), delivery_id) failed = {"status": "error", "error": "Delivery failed", "delivery_id": delivery_id} try: result = await self._direct_deliver(prompt, delivery) @@ -582,13 +571,12 @@ class WebhookAdapter(BasePlatformAdapter): self._delivery_info_created[session_chat_id] = now self._delivery_info_order.append((now, session_chat_id)) self._prune_delivery_info(now) - source = self.build_source( - chat_id=session_chat_id, chat_name=f"webhook/{route_name}", chat_type="webhook", - user_id=f"webhook:{route_name}", user_name=route_name) + source = self.build_source(chat_id=session_chat_id, chat_name=f"webhook/{route_name}", chat_type="webhook", + user_id=f"webhook:{route_name}", user_name=route_name) if profile and isinstance(profile, str): source.profile = profile - event = MessageEvent( - text=prompt, message_type=MessageType.TEXT, source=source, raw_message=payload, message_id=delivery_id) + event = MessageEvent(text=prompt, message_type=MessageType.TEXT, source=source, raw_message=payload, + message_id=delivery_id) logger.info("[webhook] %s event=%s route=%s prompt_len=%d delivery=%s", request.method, event_type, route_name, len(prompt), delivery_id) # The per-delivery session is closed by ``on_processing_complete`` once the run finishes @@ -596,13 +584,13 @@ class WebhookAdapter(BasePlatformAdapter): task = asyncio.create_task(self.handle_message(event)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) - return web.json_response( - {"status": "accepted", "route": route_name, "event": event_type, "delivery_id": delivery_id}, status=202) + return web.json_response({"status": "accepted", "route": route_name, "event": event_type, + "delivery_id": delivery_id}, status=202) async def on_processing_complete(self, event: "MessageEvent", outcome: Any) -> None: - """Close the one-shot per-delivery session: ``prune_sessions`` only reaps rows with ``ended_at`` - set, so unclosed webhook sessions leak unbounded. Fires at the true end of the run (success, - failure, cancellation); ``end_session()`` is first-reason-wins.""" + """Close the one-shot per-delivery session: ``prune_sessions`` only reaps rows with ``ended_at`` set, so + unclosed webhook sessions leak unbounded. Fires at the true end of the run; ``end_session()`` is + first-reason-wins.""" await self._end_webhook_session(event, event.source.chat_id) async def _end_webhook_session(self, event: "MessageEvent", session_chat_id: str) -> None: @@ -642,10 +630,9 @@ class WebhookAdapter(BasePlatformAdapter): return _validate_svix_signature(body, secret, *svix) # Linear (any header case): hex HMAC of the body. GitHub: sha256=. GitLab: plain token. for provided, expected in ( - (_header("linear-signature"), lambda: _hex_hmac(secret, body)), - (headers.get("X-Hub-Signature-256", ""), lambda: "sha256=" + _hex_hmac(secret, body)), - (headers.get("X-Gitlab-Token", ""), lambda: secret), - ): + (_header("linear-signature"), lambda: _hex_hmac(secret, body)), + (headers.get("X-Hub-Signature-256", ""), lambda: "sha256=" + _hex_hmac(secret, body)), + (headers.get("X-Gitlab-Token", ""), lambda: secret)): if provided: return _hmac_str_equal(provided, expected()) route_name = request.match_info.get("route_name", "") @@ -696,9 +683,7 @@ class WebhookAdapter(BasePlatformAdapter): if not isinstance(value, dict): return f"{{{key}}}" value = value.get(part, f"{{{key}}}") - if isinstance(value, (dict, list)): - return json.dumps(value, indent=2)[:2000] - return str(value) + return json.dumps(value, indent=2)[:2000] if isinstance(value, (dict, list)) else str(value) return _TEMPLATE_KEY_RE.sub(_resolve, template) @@ -756,8 +741,7 @@ class WebhookAdapter(BasePlatformAdapter): def _find_adapter(self, target_platform: Platform): """Default adapters first; multiplex may park a platform only on a secondary profile (_profile_adapters).""" - adapter = self.gateway_runner.adapters.get(target_platform) - if adapter: + if adapter := self.gateway_runner.adapters.get(target_platform): return adapter for amap in (getattr(self.gateway_runner, "_profile_adapters", None) or {}).values(): if isinstance(amap, dict) and amap.get(target_platform) is not None: From 295d09791a8ed541e23199379fc1859f616d98ad Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:52:18 -0700 Subject: [PATCH 08/12] =?UTF-8?q?refactor(gateway/platforms):=20signal.py?= =?UTF-8?q?=20=E2=80=94=20walrus=20guards,=20inline=20rpc=20id,=20fold=20c?= =?UTF-8?q?alls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/signal.py | 71 ++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 41 deletions(-) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index ddd1c01e05..06f57156a8 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -303,8 +303,8 @@ class SignalAdapter(BasePlatformAdapter): while self._running: try: logger.debug("Signal SSE: connecting to %s", url) - headers = {"Accept": "text/event-stream"} - async with self.client.stream("GET", url, headers=headers, timeout=None) as response: + async with self.client.stream("GET", url, headers={"Accept": "text/event-stream"}, + timeout=None) as response: self._sse_response = response backoff = SSE_RETRY_DELAY_INITIAL # Reset on successful connection self._last_sse_activity = time.time() @@ -327,8 +327,7 @@ class SignalAdapter(BasePlatformAdapter): if self._running: logger.warning("Signal SSE: error: %s (reconnecting in %.0fs)", e, backoff) if self._running: - # 20% jitter prevents thundering herd on reconnection - await asyncio.sleep(backoff + backoff * 0.2 * random.random()) + await asyncio.sleep(backoff + backoff * 0.2 * random.random()) # 20% jitter vs thundering herd backoff = min(backoff * 2, SSE_RETRY_DELAY_MAX) self._sse_response = None @@ -348,8 +347,7 @@ class SignalAdapter(BasePlatformAdapter): logger.warning("Signal: health check error: %s, forcing reconnect", e) self._force_reconnect() continue - if resp.status_code == 200: - # Daemon alive but SSE quiet — reset activity to avoid repeated warnings + if resp.status_code == 200: # daemon alive but SSE quiet — reset activity to avoid repeated warnings self._last_sse_activity = time.time() logger.debug("Signal: daemon healthy, SSE idle") else: @@ -566,12 +564,10 @@ class SignalAdapter(BasePlatformAdapter): """Return the preferred Signal recipient identifier for a direct chat.""" if not chat_id or chat_id.startswith("group:") or not _looks_like_e164_number(chat_id): return chat_id - cached = self._recipient_uuid_by_number.get(chat_id) - if cached: + if cached := self._recipient_uuid_by_number.get(chat_id): return cached async with self._recipient_cache_lock: - cached = self._recipient_uuid_by_number.get(chat_id) - if cached: + if cached := self._recipient_uuid_by_number.get(chat_id): return cached contacts = await self._rpc("listContacts", {"account": self.account, "allRecipients": True}) for contact in contacts if isinstance(contacts, list) else (): @@ -609,18 +605,16 @@ class SignalAdapter(BasePlatformAdapter): else cache_audio_from_bytes if _is_audio_ext(ext) else cache_document_from_bytes) return cache(raw_data, ext), ext - async def _rpc( - self, method: str, params: dict, rpc_id: str = None, *, - log_failures: bool = True, raise_on_rate_limit: bool = False, timeout: float = 30.0) -> Any: - """Send a JSON-RPC 2.0 request to signal-cli. ``log_failures=False`` logs failures at DEBUG - (typing path: silence NETWORK_FAILURE spam); ``raise_on_rate_limit=True`` raises - ``SignalRateLimitError`` on a 429 / RateLimitException instead of swallowing it.""" + async def _rpc(self, method: str, params: dict, rpc_id: str = None, *, log_failures: bool = True, + raise_on_rate_limit: bool = False, timeout: float = 30.0) -> Any: + """Send a JSON-RPC 2.0 request to signal-cli. ``log_failures=False`` logs failures at DEBUG (typing + path: silence NETWORK_FAILURE spam); ``raise_on_rate_limit=True`` raises ``SignalRateLimitError`` + on a 429 / RateLimitException instead of swallowing it.""" if not self.client: logger.warning("Signal: RPC called but client not connected") return None - if rpc_id is None: - rpc_id = f"{method}_{int(time.time() * 1000)}" - payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": rpc_id} + payload = {"jsonrpc": "2.0", "method": method, "params": params, + "id": rpc_id if rpc_id is not None else f"{method}_{int(time.time() * 1000)}"} fail_level = logging.WARNING if log_failures else logging.DEBUG try: resp = await self.client.post(f"{self.http_url}/api/v1/rpc", json=payload, timeout=timeout) @@ -637,8 +631,8 @@ class SignalAdapter(BasePlatformAdapter): if isinstance(result, dict) and raise_on_rate_limit: for r in result.get("results") if isinstance(result.get("results"), list) else (): if isinstance(r, dict) and r.get("type") == "RATE_LIMIT_FAILURE": - raise SignalRateLimitError( - "Rate limit exceeded for recipient", retry_after=r.get("retryAfterSeconds")) + raise SignalRateLimitError("Rate limit exceeded for recipient", + retry_after=r.get("retryAfterSeconds")) return result except SignalRateLimitError: raise @@ -729,8 +723,7 @@ class SignalAdapter(BasePlatformAdapter): if not content or not content.strip(): return SendResult(success=True, message_id=None) base_params = await self._with_target({"account": self.account}, chat_id) - plain_message, message_styles = self._markdown_to_signal(content) - chunks = self._split_signal_formatted_message(plain_message, message_styles, self.MAX_MESSAGE_LENGTH) + chunks = self._split_signal_formatted_message(*self._markdown_to_signal(content), self.MAX_MESSAGE_LENGTH) last_result = None for idx, (plain_text, text_styles) in enumerate(chunks, start=1): params: Dict[str, Any] = dict(base_params, message=plain_text) @@ -752,8 +745,7 @@ class SignalAdapter(BasePlatformAdapter): if not ts: return self._remember_sent_message_timestamp(ts) - now = time.monotonic() - recent = self._recent_sent_timestamps + now, recent = time.monotonic(), self._recent_sent_timestamps recent.pop(ts, None) # re-insert to mark as most-recently-used recent[ts] = now # Drop entries older than TTL first, then enforce the hard cap. @@ -795,21 +787,19 @@ class SignalAdapter(BasePlatformAdapter): return None, "download", e if not file_path or not Path(file_path).exists(): return None, "missing", None - file_size = Path(file_path).stat().st_size - if file_size > SIGNAL_MAX_ATTACHMENT_SIZE: + if (file_size := Path(file_path).stat().st_size) > SIGNAL_MAX_ATTACHMENT_SIZE: return None, "oversize", file_size return file_path, None, None - async def send_multiple_images( - self, chat_id: str, images: List[Tuple[str, str]], metadata: Optional[Dict[str, Any]] = None, - human_delay: float = 0.0) -> None: + async def send_multiple_images(self, chat_id: str, images: List[Tuple[str, str]], + metadata: Optional[Dict[str, Any]] = None, human_delay: float = 0.0) -> None: """Send a batch of images via chunked Signal RPC calls. Alt texts are dropped (one shared body per send); bad images are skipped with a warning; ``human_delay`` is ignored (scheduler paces).""" if not images: return scheduler = get_scheduler() - logger.info("Signal send_multiple_images: received %d image(s) for %s — scheduler state: %s", - len(images), chat_id[:30], scheduler.state()) + logger.info("Signal send_multiple_images: received %d image(s) for %s — scheduler state: %s", len(images), + chat_id[:30], scheduler.state()) await self._stop_typing_indicator(chat_id) attachments: List[str] = [] skipped = {"download": 0, "missing": 0, "oversize": 0} @@ -821,8 +811,8 @@ class SignalAdapter(BasePlatformAdapter): skipped[reason] += 1 logger.warning(*_SKIP_IMAGE_LOG[reason](image_url, detail)) if not attachments: - logger.error("Signal: no valid images in batch of %d (download=%d missing=%d oversize=%d)", - len(images), skipped["download"], skipped["missing"], skipped["oversize"]) + logger.error("Signal: no valid images in batch of %d (download=%d missing=%d oversize=%d)", len(images), + skipped["download"], skipped["missing"], skipped["oversize"]) return logger.info("Signal send_multiple_images: %d/%d images valid, sending in chunks", len(attachments), len(images)) base_params = await self._with_target({"account": self.account, "message": ""}, chat_id) @@ -835,8 +825,8 @@ class SignalAdapter(BasePlatformAdapter): logger.debug("Signal batch %d/%d: %d attachments, estimated wait=%.1fs", idx, n_batches, n, estimated) if estimated >= SIGNAL_BATCH_PACING_NOTICE_THRESHOLD: await self._notify_batch_pacing(chat_id, idx, n_batches, estimated) - await self._send_attachment_batch( - scheduler, dict(base_params, attachments=att_batch), n, f"{idx}/{n_batches}") + await self._send_attachment_batch(scheduler, dict(base_params, attachments=att_batch), n, + f"{idx}/{n_batches}") async def _send_attachment_batch(self, scheduler, params: Dict[str, Any], n: int, label: str) -> None: """Send one attachment batch with rate-limit pacing and a single transient retry. Tokens are @@ -862,16 +852,15 @@ class SignalAdapter(BasePlatformAdapter): if success: self._track_sent_timestamp(result) await scheduler.report_rpc_duration(duration, n) - logger.info("Signal batch %s: %d attachments sent in %.1fs (attempt %d/%d)", - label, n, duration, attempt, max_attempts) + logger.info("Signal batch %s: %d attachments sent in %.1fs (attempt %d/%d)", label, n, duration, + attempt, max_attempts) return logger.error("Signal: RPC send failed for batch %s (%d attachments, attempt %d/%d, rpc_duration=%.1fs)%s", label, n, attempt, max_attempts, duration, f": {err_msg}" if result is not None else "") if attempt >= max_attempts: return - backoff = 2.0 ** attempt - logger.info("Signal: retrying batch %s after %.1fs backoff", label, backoff) - await asyncio.sleep(backoff) + logger.info("Signal: retrying batch %s after %.1fs backoff", label, 2.0 ** attempt) + await asyncio.sleep(2.0 ** attempt) async def _notify_batch_pacing(self, chat_id: str, next_batch_idx: int, total_batches: int, wait_s: float) -> None: """Tell the user about an inter-batch pacing wait over the notice threshold (best-effort).""" From 16e2be22571c8237c8f3a64e3b9ab00bd90a00f6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:56:15 -0700 Subject: [PATCH 09/12] =?UTF-8?q?refactor(gateway/platforms):=20signal=5Ff?= =?UTF-8?q?ormat.py=20=E2=80=94=20compact=20docs,=20fold=20bullet=20normal?= =?UTF-8?q?izer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/signal_format.py | 52 +++++++++++------------------- 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/gateway/platforms/signal_format.py b/gateway/platforms/signal_format.py index b36e59283c..a9734e78e1 100644 --- a/gateway/platforms/signal_format.py +++ b/gateway/platforms/signal_format.py @@ -1,8 +1,5 @@ -"""Shared Signal formatting helpers. - -Markdown → Signal native formatting lives here so both the live adapter and the -standalone send paths emit the same bodyRanges. -""" +"""Shared Signal formatting helpers: Markdown → Signal native formatting lives here so both the live +adapter and the standalone send paths emit the same bodyRanges.""" from __future__ import annotations @@ -25,32 +22,24 @@ def _utf16_len(s: str) -> int: def _normalize_bullet_markers(source: str) -> str: - """Replace Markdown bullet markers with plain Unicode bullets. - - Signal renders ``- item`` / ``* item`` literally. Fenced code blocks are kept - byte-for-byte; list-looking lines inside code are code, not prose bullets.""" + """Replace Markdown bullet markers with plain Unicode bullets (Signal renders ``- item`` literally). + Fenced code blocks are kept byte-for-byte: list-looking lines inside code are code, not bullets.""" parts = re.split(r"(```.*?```)", source, flags=re.DOTALL) - for idx, part in enumerate(parts): - if idx % 2 == 0: - parts[idx] = re.sub(r"(?m)^([ \t]{0,3})[-*+]\s+", r"\1• ", part) - return "".join(parts) + return "".join(re.sub(r"(?m)^([ \t]{0,3})[-*+]\s+", r"\1• ", part) if idx % 2 == 0 else part + for idx, part in enumerate(parts)) def markdown_to_signal(text: str) -> tuple[str, list[str]]: - """Convert markdown to plain text + Signal textStyles list. - - Signal uses ``bodyRanges`` (signal-cli ``textStyle`` / ``textStyles`` params) in - the form ``start:length:STYLE``, positions in UTF-16 code units. + """Convert markdown to plain text + Signal textStyles list. Signal uses ``bodyRanges`` (signal-cli + ``textStyle`` / ``textStyles`` params) as ``start:length:STYLE`` with positions in UTF-16 code units. Supported styles: BOLD, ITALIC, STRIKETHROUGH, MONOSPACE.""" - text = re.sub(r"\n{3,}", "\n\n", text).strip() - text = _normalize_bullet_markers(text) + text = _normalize_bullet_markers(re.sub(r"\n{3,}", "\n\n", text).strip()) styles: list[tuple[int, int, str]] = [] while match := _CODE_BLOCK_RE.search(text): inner = match.group(1).rstrip("\n") styles.append((match.start(), len(inner), "MONOSPACE")) text = text[: match.start()] + inner + text[match.end() :] - new_text = "" - last_end = 0 + new_text, last_end = "", 0 for match in _HEADING_RE.finditer(text): new_text += text[last_end : match.start()] eol = text.find("\n", match.end()) @@ -71,10 +60,9 @@ def markdown_to_signal(text: str) -> tuple[str, list[str]]: all_matches.append((ms, me, match.start(1), match.end(1), style)) occupied.append((ms, me)) all_matches.sort() - # Strip the markers, recording (pos, len) removals so earlier block/heading - # ranges can be shifted, and capturing inline ranges in the stripped text. - result = "" - last_end = 0 + # Strip the markers, recording (pos, len) removals so earlier block/heading ranges can be + # shifted, and capturing inline ranges in the stripped text. + result, last_end = "", 0 removals: list[tuple[int, int]] = [] inline_styles: list[tuple[int, int, str]] = [] for ms, me, g1s, g1e, style in all_matches: @@ -97,16 +85,12 @@ def markdown_to_signal(text: str) -> tuple[str, list[str]]: shift += min(remove_len, pos - remove_pos) return pos - shift - adjusted_prior = [ - (_adjust(start), _adjust(start + length) - _adjust(start), style) - for start, length, style in styles - if _adjust(start + length) > _adjust(start)] + adjusted_prior = [(_adjust(start), _adjust(start + length) - _adjust(start), style) + for start, length, style in styles if _adjust(start + length) > _adjust(start)] text = result + text[last_end:] style_strings: list[str] = [] for cp_start, cp_len, style_type in sorted(adjusted_prior + inline_styles): - if cp_start < 0 or cp_start + cp_len > len(text): - continue - u16_start = _utf16_len(text[:cp_start]) - u16_len = _utf16_len(text[cp_start : cp_start + cp_len]) - style_strings.append(f"{u16_start}:{u16_len}:{style_type}") + if 0 <= cp_start and cp_start + cp_len <= len(text): + u16_start, u16_len = _utf16_len(text[:cp_start]), _utf16_len(text[cp_start : cp_start + cp_len]) + style_strings.append(f"{u16_start}:{u16_len}:{style_type}") return text, style_strings From ee82009a2354dd3a4e9f4ffc087eca6a9615026b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:59:35 -0700 Subject: [PATCH 10/12] =?UTF-8?q?refactor(gateway/platforms):=20bluebubble?= =?UTF-8?q?s.py=20=E2=80=94=20collapse=20chunking,=20guid=20resolution=20g?= =?UTF-8?q?uards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/bluebubbles.py | 54 ++++++++++++-------------------- 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index b51b9dd0be..fbf33f29de 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -118,9 +118,8 @@ class BlueBubblesAdapter(BasePlatformAdapter): self.password = extra.get("password") or _get_scoped_secret("BLUEBUBBLES_PASSWORD", "") self.webhook_host = _setting(extra, "webhook_host", "BLUEBUBBLES_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST) self.webhook_port = int(_setting(extra, "webhook_port", "BLUEBUBBLES_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT))) - self.webhook_path = _setting(extra, "webhook_path", "BLUEBUBBLES_WEBHOOK_PATH", DEFAULT_WEBHOOK_PATH) - if not str(self.webhook_path).startswith("/"): - self.webhook_path = f"/{self.webhook_path}" + path = str(_setting(extra, "webhook_path", "BLUEBUBBLES_WEBHOOK_PATH", DEFAULT_WEBHOOK_PATH)) + self.webhook_path = path if path.startswith("/") else f"/{path}" self.send_read_receipts = bool(extra.get("send_read_receipts", True)) _require_mention = extra.get("require_mention") if _require_mention is None: @@ -137,8 +136,7 @@ class BlueBubblesAdapter(BasePlatformAdapter): # --- API helpers --- def _api_url(self, path: str) -> str: - sep = "&" if "?" in path else "?" - return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}" + return f"{self.server_url}{path}{'&' if '?' in path else '?'}password={quote(self.password, safe='')}" @staticmethod def _compile_mention_patterns(raw: Any) -> List[re.Pattern]: @@ -321,10 +319,8 @@ class BlueBubblesAdapter(BasePlatformAdapter): the same contact appears in a 1:1 DM and any number of groups, so a participant match could leak a DM reply into a group thread. ``None`` lets the caller create a fresh DM.""" target = (target or "").strip() - if not target: - return None - if ";" in target: - return target + if not target or ";" in target: + return target or None if target in self._guid_cache: self._guid_cache.move_to_end(target) return self._guid_cache[target] @@ -333,8 +329,7 @@ class BlueBubblesAdapter(BasePlatformAdapter): for chat in payload.get("data", []) or []: if (chat.get("chatIdentifier") or chat.get("identifier")) != target: continue - guid = chat.get("guid") or chat.get("chatGuid") - if guid: + if guid := chat.get("guid") or chat.get("chatGuid"): self._guid_cache[target] = guid while len(self._guid_cache) > _GUID_CACHE_SIZE: self._guid_cache.popitem(last=False) @@ -351,8 +346,7 @@ class BlueBubblesAdapter(BasePlatformAdapter): @staticmethod def truncate_message(content: str, max_length: int = MAX_TEXT_LENGTH) -> List[str]: # Base splitter minus "(1/3)" pagination suffixes — iMessage bubbles flow naturally. - chunks = BasePlatformAdapter.truncate_message(content, max_length) - return [_PAGINATION_SUFFIX_RE.sub("", c) for c in chunks] + return [_PAGINATION_SUFFIX_RE.sub("", c) for c in BasePlatformAdapter.truncate_message(content, max_length)] async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult: @@ -360,11 +354,9 @@ class BlueBubblesAdapter(BasePlatformAdapter): if not text: return SendResult(success=False, error="BlueBubbles send requires text") # Each paragraph becomes its own iMessage bubble; truncate any still too long. - paragraphs = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()] - chunks: List[str] = [] - for para in (paragraphs or [text]): - chunks.extend([para] if len(para) <= self.MAX_MESSAGE_LENGTH - else self.truncate_message(para, max_length=self.MAX_MESSAGE_LENGTH)) + paragraphs = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()] or [text] + chunks = [c for para in paragraphs for c in ( + [para] if len(para) <= self.MAX_MESSAGE_LENGTH else self.truncate_message(para, self.MAX_MESSAGE_LENGTH))] last = SendResult(success=True) for chunk in chunks: guid = await self._resolve_chat_guid(chat_id) @@ -375,8 +367,7 @@ class BlueBubblesAdapter(BasePlatformAdapter): payload: Dict[str, Any] = {"chatGuid": guid, "tempGuid": _temp_guid(), "message": chunk} if reply_to and self._private_api_enabled and self._helper_connected: payload.update(method="private-api", selectedMessageGuid=reply_to, partIndex=0) - last = await self._post_message("/api/v1/message/text", payload) - if not last.success: + if not (last := await self._post_message("/api/v1/message/text", payload)).success: return last return last @@ -418,8 +409,7 @@ class BlueBubblesAdapter(BasePlatformAdapter): reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult: try: from gateway.platforms.base import cache_image_from_url - local_path = await cache_image_from_url(image_url) - return await self._send_attachment(chat_id, local_path, caption=caption) + return await self._send_attachment(chat_id, await cache_image_from_url(image_url), caption=caption) except Exception: return await super().send_image(chat_id, image_url, caption, reply_to) @@ -533,8 +523,7 @@ class BlueBubblesAdapter(BasePlatformAdapter): is_voice = mime.startswith("audio/") or (att.get("uti") or "").endswith("caf") msg_type = (MessageType.PHOTO if mime.startswith("image/") else MessageType.VOICE if is_voice else MessageType.VIDEO if mime.startswith("video/") else MessageType.DOCUMENT) - # With multiple attachments, prefer PHOTO if any images present - if len(media_urls) > 1 and any(m.split("/")[0] == "image" for m in media_types): + if len(media_urls) > 1 and any(m.split("/")[0] == "image" for m in media_types): # any image → PHOTO msg_type = MessageType.PHOTO return media_urls, media_types, msg_type @@ -544,16 +533,14 @@ class BlueBubblesAdapter(BasePlatformAdapter): def _resolve_chat_and_sender(self, payload: Dict[str, Any], record: Dict[str, Any]): """Returns ``(chat_guid, chat_identifier, sender)`` from the many BlueBubbles payload shapes.""" - chat_guid = self._value( - record.get("chatGuid"), payload.get("chatGuid"), - record.get("chat_guid"), payload.get("chat_guid"), payload.get("guid")) + chat_guid = self._value(record.get("chatGuid"), payload.get("chatGuid"), record.get("chat_guid"), + payload.get("chat_guid"), payload.get("guid")) # BlueBubbles v1.9+ payloads omit top-level chatGuid; it's nested under data.chats[0].guid. _chats = record.get("chats") or [] if not chat_guid and _chats and isinstance(_chats[0], dict): chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid") - chat_identifier = self._value( - record.get("chatIdentifier"), record.get("identifier"), - payload.get("chatIdentifier"), payload.get("identifier")) + chat_identifier = self._value(record.get("chatIdentifier"), record.get("identifier"), + payload.get("chatIdentifier"), payload.get("identifier")) handle = record.get("handle") sender = (self._value(handle.get("address") if isinstance(handle, dict) else None, record.get("sender"), record.get("from"), record.get("address")) or chat_identifier or chat_guid) @@ -594,10 +581,9 @@ class BlueBubblesAdapter(BasePlatformAdapter): logger.debug("[bluebubbles] ignoring group message (require_mention=true, no mention pattern matched)") return _ok() text = self._clean_mention_text(text) - source = self.build_source( - chat_id=session_chat_id, chat_name=chat_identifier or sender, - chat_type="group" if is_group else "dm", user_id=sender, user_name=sender, - chat_id_alt=chat_identifier) + source = self.build_source(chat_id=session_chat_id, chat_name=chat_identifier or sender, + chat_type="group" if is_group else "dm", user_id=sender, user_name=sender, + chat_id_alt=chat_identifier) event = MessageEvent( text=text, message_type=msg_type, source=source, raw_message=payload, message_id=self._value(record.get("guid"), record.get("messageGuid"), record.get("id")), From 873405895f0720b354d1e485ba5090cc64a9116d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:03:43 -0700 Subject: [PATCH 11/12] =?UTF-8?q?refactor(gateway/platforms):=20signal.py?= =?UTF-8?q?=20=E2=80=94=20walrus=20guards=20in=20reaction/typing/mention?= =?UTF-8?q?=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/signal.py | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 06f57156a8..8aa5ba9864 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -383,19 +383,16 @@ class SignalAdapter(BasePlatformAdapter): account_norm = self._account_normalized if self.require_mention: mentioned_in_text = account_norm and (f"@{account_norm}" in (text or "")) - mentioned_in_metadata = any( - m.get("number") == account_norm or m.get("uuid") == account_norm - for m in (data_message.get("mentions") or [])) + mentioned_in_metadata = any(account_norm in (m.get("number"), m.get("uuid")) + for m in (data_message.get("mentions") or [])) if not mentioned_in_text and not mentioned_in_metadata: logger.debug("Signal: ignoring group message (require_mention=true, bot not mentioned)") return False, text if text and account_norm: text = text.replace(f"@{account_norm}", "") - bot_uuid = self._recipient_uuid_by_number.get(account_norm) - if bot_uuid: + if bot_uuid := self._recipient_uuid_by_number.get(account_norm): text = text.replace(f"@{bot_uuid}", "") - # Collapse only the doubled space the removal introduced; newlines are preserved. - text = text.replace(" ", " ").strip() + text = text.replace(" ", " ").strip() # collapse only the doubled space; newlines preserved return True, text async def _collect_attachments(self, attachments_data: list) -> Tuple[List[str], List[str]]: @@ -707,8 +704,7 @@ class SignalAdapter(BasePlatformAdapter): async def _rpc_send(self, params: Dict[str, Any], fail_error: str) -> Tuple[Any, Optional[SendResult]]: """Run a ``send`` RPC, validate and track it; ``(result, None)`` or ``(None, failed SendResult)``.""" - result = await self._rpc("send", params) - if result is None: + if (result := await self._rpc("send", params)) is None: return None, SendResult(success=False, error=fail_error) success, err_msg = self._validate_send_result(result) if not success: @@ -767,8 +763,7 @@ class SignalAdapter(BasePlatformAdapter): return params = await self._with_target({"account": self.account}, chat_id) fails = self._typing_failures.get(chat_id, 0) - result = await self._rpc("sendTyping", params, rpc_id="typing", log_failures=(fails == 0)) - if result is not None: + if await self._rpc("sendTyping", params, rpc_id="typing", log_failures=(fails == 0)) is not None: self._typing_failures.pop(chat_id, None) self._typing_skip_until.pop(chat_id, None) return @@ -865,8 +860,8 @@ class SignalAdapter(BasePlatformAdapter): async def _notify_batch_pacing(self, chat_id: str, next_batch_idx: int, total_batches: int, wait_s: float) -> None: """Tell the user about an inter-batch pacing wait over the notice threshold (best-effort).""" try: - await self.send(chat_id, f"(More images coming — pausing ~{_format_wait(wait_s)} " - f"for Signal rate limit, batch {next_batch_idx}/{total_batches}.)") + await self.send(chat_id, f"(More images coming — pausing ~{_format_wait(wait_s)} for Signal rate limit, " + f"batch {next_batch_idx}/{total_batches}.)") except Exception as e: logger.warning("Signal: failed to send pacing notice: %s", e) @@ -967,18 +962,14 @@ class SignalAdapter(BasePlatformAdapter): async def on_processing_start(self, event: MessageEvent) -> None: """React with 👀 when processing begins.""" - if not self._reactions_enabled(event): - return - target = self._extract_reaction_target(event) - if target: + if self._reactions_enabled(event) and (target := self._extract_reaction_target(event)): await self.send_reaction(event.source.chat_id, "👀", *target) async def on_processing_complete(self, event: MessageEvent, outcome: "ProcessingOutcome") -> None: """Swap 👀 for ✅/❌; on CANCELLED the 👀 stays to keep reflecting "in progress" (matches Telegram).""" if outcome == ProcessingOutcome.CANCELLED or not self._reactions_enabled(event): return - target = self._extract_reaction_target(event) - if not target: + if not (target := self._extract_reaction_target(event)): return await self.remove_reaction(event.source.chat_id, *target) if emoji := _OUTCOME_REACTION.get(outcome): From 4009eeb59f9aa391e397c2e5f1f4ab591dd58096 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:13:26 -0700 Subject: [PATCH 12/12] =?UTF-8?q?refactor(gateway/platforms):=20webhook/ms?= =?UTF-8?q?graph/keyboards/chunked=5Fupload=20=E2=80=94=20walrus=20guards,?= =?UTF-8?q?=20fold=20small=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/msgraph_webhook.py | 12 ++++-------- gateway/platforms/qqbot/chunked_upload.py | 6 ++---- gateway/platforms/qqbot/keyboards.py | 9 +++------ gateway/platforms/signal.py | 24 +++++++++-------------- gateway/platforms/webhook.py | 17 ++++++---------- 5 files changed, 24 insertions(+), 44 deletions(-) diff --git a/gateway/platforms/msgraph_webhook.py b/gateway/platforms/msgraph_webhook.py index a87a303e06..3e59f76793 100644 --- a/gateway/platforms/msgraph_webhook.py +++ b/gateway/platforms/msgraph_webhook.py @@ -116,8 +116,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): self._notification_scheduler: Optional[NotificationScheduler] = None self._seen_receipts: set[str] = set() self._seen_receipt_order: deque[str] = deque() - self._accepted_count = 0 - self._duplicate_count = 0 + self._accepted_count = self._duplicate_count = 0 def set_notification_scheduler(self, scheduler: Optional[NotificationScheduler]) -> None: self._notification_scheduler = scheduler @@ -176,8 +175,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): are rejected so the endpoint can't be enumerated.""" if not self._source_ip_allowed(request): return web.Response(status=403) - validation_token = request.query.get("validationToken", "") - if not validation_token: + if not (validation_token := request.query.get("validationToken", "")): return web.Response(status=400) return web.Response(text=validation_token, content_type="text/plain") @@ -192,8 +190,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): # Bad clientState is an auth failure: a fully forged batch gets 403 so the sender stops # retrying; legitimate Graph retries carry a valid clientState → accepted/duplicate paths. return "auth" - explicit_id = str(notification.get("id") or "").strip() - receipt_key = f"id:{explicit_id}" if explicit_id else None + receipt_key = f"id:{explicit_id}" if (explicit_id := str(notification.get("id") or "").strip()) else None if receipt_key is not None: if receipt_key in self._seen_receipts: return "duplicate" @@ -206,8 +203,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): if not self._source_ip_allowed(request): return web.Response(status=403) # Graph never sends validationToken on POST, but tolerate clients replaying it in-band. - validation_token = request.query.get("validationToken", "") - if validation_token: + if validation_token := request.query.get("validationToken", ""): return web.Response(text=validation_token, content_type="text/plain") status, notifications = await self._read_notifications(request) if status: diff --git a/gateway/platforms/qqbot/chunked_upload.py b/gateway/platforms/qqbot/chunked_upload.py index 87434faffa..874a844819 100644 --- a/gateway/platforms/qqbot/chunked_upload.py +++ b/gateway/platforms/qqbot/chunked_upload.py @@ -35,8 +35,7 @@ _MD5_10M_SIZE = 10_002_432 # first N bytes used for the ``md5_10m`` hash (per Q class _UploadError(Exception): def __init__(self, file_name: str, file_size: int, message: str) -> None: - self.file_name = file_name - self.file_size = file_size + self.file_name, self.file_size = file_name, file_size super().__init__(message) @property @@ -84,8 +83,7 @@ class _PrepareResult: def _parse_prepare_response(raw: Dict[str, Any]) -> _PrepareResult: """Parse upload_prepare response (either bare or wrapped in ``data``).""" src = raw.get("data") if isinstance(raw.get("data"), dict) else raw - upload_id = str(src.get("upload_id", "")) - if not upload_id: + if not (upload_id := str(src.get("upload_id", ""))): raise ValueError(f"upload_prepare response missing upload_id: {str(raw)[:200]}") block_size = int(src.get("block_size", 0)) raw_parts = src.get("parts") or src.get("part_list") or [] diff --git a/gateway/platforms/qqbot/keyboards.py b/gateway/platforms/qqbot/keyboards.py index a6f205ce9d..490b8e6ae7 100644 --- a/gateway/platforms/qqbot/keyboards.py +++ b/gateway/platforms/qqbot/keyboards.py @@ -79,14 +79,12 @@ class InlineKeyboard(_Serializable): def parse_approval_button_data(button_data: str) -> Optional[tuple[str, str]]: """Parse approval ``button_data`` into ``(session_key, decision)`` or ``None``.""" - m = _APPROVAL_DATA_RE.match(button_data or "") - return m.groups() if m else None + return m.groups() if (m := _APPROVAL_DATA_RE.match(button_data or "")) else None def parse_update_prompt_button_data(button_data: str) -> Optional[str]: """Parse update-prompt ``button_data`` into ``'y'`` / ``'n'`` or ``None``.""" - m = _UPDATE_PROMPT_RE.match(button_data or "") - return m.group(1) if m else None + return m.group(1) if (m := _UPDATE_PROMPT_RE.match(button_data or "")) else None def _single_row_keyboard(group_id: str, *buttons: tuple) -> InlineKeyboard: @@ -183,8 +181,7 @@ _SCENE_NAMES = {0: "guild", 1: "group", 2: "c2c"} def parse_interaction_event(raw: Dict[str, Any]) -> InteractionEvent: """Parse a raw ``INTERACTION_CREATE`` dispatch payload (``d``).""" data_raw = raw.get("data") or {} - resolved = data_raw.get("resolved") or {} - scene_code = int(raw.get("chat_type", 0) or 0) + resolved, scene_code = data_raw.get("resolved") or {}, int(raw.get("chat_type", 0) or 0) return InteractionEvent( id=str(raw.get("id", "")), type=int(data_raw.get("type", 0) or 0), chat_type=scene_code, scene=_SCENE_NAMES.get(scene_code, ""), group_openid=str(raw.get("group_openid", "")), diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 8aa5ba9864..c50650ab24 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -74,8 +74,7 @@ def _guess_extension(data: bytes) -> str: return ext if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP": return ".webp" - container = sniff_container(data) - if container is not None: + if (container := sniff_container(data)) is not None: return CONTAINER_TO_EXT[container] return ".zip" if data[:2] == b"PK" else ".bin" @@ -225,8 +224,7 @@ class SignalAdapter(BasePlatformAdapter): if not self.http_url or not self.account: logger.error("Signal: SIGNAL_HTTP_URL and SIGNAL_ACCOUNT are required") return False - # Scoped lock prevents duplicate Signal listeners for the same phone. - lock_acquired = False + lock_acquired = False # scoped lock prevents duplicate Signal listeners for the same phone try: if not self._acquire_platform_lock('signal-phone', self.account, 'Signal account'): return False @@ -337,8 +335,7 @@ class SignalAdapter(BasePlatformAdapter): await asyncio.sleep(HEALTH_CHECK_INTERVAL) if not self._running: break - elapsed = time.time() - self._last_sse_activity - if elapsed <= HEALTH_CHECK_STALE_THRESHOLD: + if (elapsed := time.time() - self._last_sse_activity) <= HEALTH_CHECK_STALE_THRESHOLD: continue logger.warning("Signal: SSE idle for %.0fs, checking daemon health", elapsed) try: @@ -510,17 +507,15 @@ class SignalAdapter(BasePlatformAdapter): def _remember_recipient_identifiers(self, number: Optional[str], service_id: Optional[str]) -> None: """Cache any number↔UUID mapping observed from Signal envelopes.""" - if not number or not service_id or not _is_signal_service_id(service_id): - return - self._recipient_uuid_by_number[number] = service_id - self._recipient_number_by_uuid[service_id] = number + if number and service_id and _is_signal_service_id(service_id): + self._recipient_uuid_by_number[number] = service_id + self._recipient_number_by_uuid[service_id] = number @staticmethod def _extract_quote_author(quote_data: Any) -> Optional[str]: """Return the best available Signal sender identifier from quote metadata.""" - if not isinstance(quote_data, dict): - return None - return next((str(quote_data[k]) for k in _QUOTE_AUTHOR_KEYS if quote_data.get(k)), None) + keys = _QUOTE_AUTHOR_KEYS if isinstance(quote_data, dict) else () + return next((str(quote_data[k]) for k in keys if quote_data.get(k)), None) def _quote_references_own_message(self, reply_to_id: Optional[str], reply_to_author: Optional[str]) -> bool: """True when a Signal quote points at this adapter's outbound message.""" @@ -538,8 +533,7 @@ class SignalAdapter(BasePlatformAdapter): if timestamp is None: return key = str(timestamp) - # Re-insert to mark most-recently-used so eviction drops genuinely old entries. - self._sent_message_timestamps.pop(key, None) + self._sent_message_timestamps.pop(key, None) # re-insert as most-recently-used so eviction drops old ones self._sent_message_timestamps[key] = None while len(self._sent_message_timestamps) > self._max_sent_message_timestamps: self._sent_message_timestamps.popitem(last=False) diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index e8fa8a0566..c84022b468 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -105,8 +105,7 @@ def _json_error(message: str, status: int) -> "web.Response": def _peek_session_id(store, session_key: str): """Prefer the store's lock-held accessor; the private-path fallback is for older stores / test doubles.""" - peek = getattr(store, "peek_session_id", None) - if callable(peek): + if callable(peek := getattr(store, "peek_session_id", None)): return peek(session_key) if hasattr(store, "_ensure_loaded"): with suppress(Exception): @@ -285,8 +284,7 @@ class WebhookAdapter(BasePlatformAdapter): def _record_rate_limit_hit(self, route_name: str, now: float) -> bool: """Return True if route is still within limit after recording this hit.""" - window = self._rate_counts.get(route_name) - if not isinstance(window, deque): + if not isinstance(window := self._rate_counts.get(route_name), deque): window = self._rate_counts[route_name] = deque(window or ()) cutoff = now - _RATE_WINDOW_SECONDS while window and window[0] < cutoff: @@ -298,8 +296,7 @@ class WebhookAdapter(BasePlatformAdapter): def _record_delivery_id(self, delivery_id: str, now: float) -> bool: """Return True when this delivery should be processed.""" - seen_at = self._seen_deliveries.get(delivery_id) - if seen_at is not None and now - seen_at < self._idempotency_ttl: + if (seen_at := self._seen_deliveries.get(delivery_id)) is not None and now - seen_at < self._idempotency_ttl: return False if seen_at is not None: self._seen_deliveries.pop(delivery_id, None) @@ -462,9 +459,8 @@ class WebhookAdapter(BasePlatformAdapter): logger.exception("[webhook] direct-deliver failed route=%s delivery=%s", route_name, delivery_id) return web.json_response(failed, status=502) if result.success: - return web.json_response( - {"status": "delivered", "route": route_name, "target": delivery["deliver"], "delivery_id": delivery_id}, - status=200) + return web.json_response({"status": "delivered", "route": route_name, "target": delivery["deliver"], + "delivery_id": delivery_id}, status=200) # Target rejected it — 502 with a generic error (don't leak adapter detail). logger.warning("[webhook] direct-deliver target rejected route=%s target=%s error=%s", route_name, delivery["deliver"], result.error) @@ -756,8 +752,7 @@ class WebhookAdapter(BasePlatformAdapter): target_platform = Platform(platform_name) except ValueError: return SendResult(success=False, error=f"Unknown platform: {platform_name}") - adapter = self._find_adapter(target_platform) - if not adapter: + if not (adapter := self._find_adapter(target_platform)): return SendResult(success=False, error=f"Platform {platform_name} not connected") extra = delivery.get("deliver_extra", {}) chat_id = extra.get("chat_id", "")