fix(agent): scope subagent reply dedupe to origin message
Made-with: Cursor
This commit is contained in:
+162
-25
@@ -9,7 +9,7 @@ import zipfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import unquote, urlparse
|
||||
from urllib.parse import unquote, urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -19,6 +19,10 @@ from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.schema import Base
|
||||
from nanobot.security.network import validate_resolved_url, validate_url_target
|
||||
|
||||
DINGTALK_MAX_REMOTE_MEDIA_BYTES = 20 * 1024 * 1024
|
||||
DINGTALK_MAX_REMOTE_MEDIA_REDIRECTS = 3
|
||||
|
||||
try:
|
||||
from dingtalk_stream import (
|
||||
@@ -155,6 +159,8 @@ class DingTalkConfig(Base):
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
allow_remote_media_redirects: bool = False
|
||||
remote_media_redirect_allowed_hosts: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DingTalkChannel(BaseChannel):
|
||||
@@ -281,9 +287,12 @@ class DingTalkChannel(BaseChannel):
|
||||
|
||||
def _guess_upload_type(self, media_ref: str) -> str:
|
||||
ext = Path(urlparse(media_ref).path).suffix.lower()
|
||||
if ext in self._IMAGE_EXTS: return "image"
|
||||
if ext in self._AUDIO_EXTS: return "voice"
|
||||
if ext in self._VIDEO_EXTS: return "video"
|
||||
if ext in self._IMAGE_EXTS:
|
||||
return "image"
|
||||
if ext in self._AUDIO_EXTS:
|
||||
return "voice"
|
||||
if ext in self._VIDEO_EXTS:
|
||||
return "video"
|
||||
return "file"
|
||||
|
||||
def _guess_filename(self, media_ref: str, upload_type: str) -> str:
|
||||
@@ -315,6 +324,146 @@ class DingTalkChannel(BaseChannel):
|
||||
return self._zip_bytes(filename, data)
|
||||
return data, filename, content_type
|
||||
|
||||
def _validate_remote_media_url(self, media_ref: str) -> bool:
|
||||
ok, err = validate_url_target(media_ref)
|
||||
if not ok:
|
||||
logger.warning("DingTalk remote media URL blocked ref={} reason={}", media_ref, err)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _redirect_host_allowed(self, current_url: str, next_url: str) -> bool:
|
||||
current_host = (urlparse(current_url).hostname or "").lower()
|
||||
next_host = (urlparse(next_url).hostname or "").lower()
|
||||
if not next_host:
|
||||
return False
|
||||
if next_host == current_host:
|
||||
return True
|
||||
allowed_hosts = {host.lower() for host in self.config.remote_media_redirect_allowed_hosts}
|
||||
return next_host in allowed_hosts
|
||||
|
||||
def _next_remote_media_url(self, current_url: str, location: str | None) -> str | None:
|
||||
if not self.config.allow_remote_media_redirects:
|
||||
logger.warning("DingTalk media download redirect refused ref={}", current_url)
|
||||
return None
|
||||
if not location:
|
||||
logger.warning("DingTalk media download redirect without Location ref={}", current_url)
|
||||
return None
|
||||
next_url = urljoin(current_url, location)
|
||||
if not self._redirect_host_allowed(current_url, next_url):
|
||||
logger.warning(
|
||||
"DingTalk media download cross-host redirect refused ref={} next={}",
|
||||
current_url,
|
||||
next_url,
|
||||
)
|
||||
return None
|
||||
if not self._validate_remote_media_url(next_url):
|
||||
return None
|
||||
return next_url
|
||||
|
||||
async def _fetch_remote_media_bytes(
|
||||
self,
|
||||
media_ref: str,
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
"""Fetch a remote media URL with SSRF, redirect, and size checks."""
|
||||
if not self._http:
|
||||
return None, None
|
||||
|
||||
if not self._validate_remote_media_url(media_ref):
|
||||
return None, None
|
||||
|
||||
try:
|
||||
# Prefer streaming with a running byte cap so large responses are not
|
||||
# materialized before the limit is enforced. Test fakes may only
|
||||
# implement get(), so keep a small compatibility fallback below.
|
||||
stream = getattr(self._http, "stream", None)
|
||||
if stream is not None:
|
||||
current_url = media_ref
|
||||
for _ in range(DINGTALK_MAX_REMOTE_MEDIA_REDIRECTS + 1):
|
||||
async with stream("GET", current_url, follow_redirects=False) as resp:
|
||||
final_ok, final_err = validate_resolved_url(str(resp.url))
|
||||
if not final_ok:
|
||||
logger.warning(
|
||||
"DingTalk remote media redirect blocked ref={} final={} reason={}",
|
||||
media_ref,
|
||||
resp.url,
|
||||
final_err,
|
||||
)
|
||||
return None, None
|
||||
if 300 <= resp.status_code < 400:
|
||||
next_url = self._next_remote_media_url(
|
||||
str(resp.url), resp.headers.get("location")
|
||||
)
|
||||
if not next_url:
|
||||
return None, None
|
||||
current_url = next_url
|
||||
continue
|
||||
if resp.status_code >= 400:
|
||||
logger.warning(
|
||||
"DingTalk media download failed status={} ref={}",
|
||||
resp.status_code,
|
||||
current_url,
|
||||
)
|
||||
return None, None
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in resp.aiter_bytes():
|
||||
total += len(chunk)
|
||||
if total > DINGTALK_MAX_REMOTE_MEDIA_BYTES:
|
||||
logger.warning(
|
||||
"DingTalk media download too large ref={} bytes>{}",
|
||||
current_url,
|
||||
DINGTALK_MAX_REMOTE_MEDIA_BYTES,
|
||||
)
|
||||
return None, None
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks), (resp.headers.get("content-type") or "")
|
||||
logger.warning("DingTalk media download exceeded redirect limit ref={}", media_ref)
|
||||
return None, None
|
||||
|
||||
current_url = media_ref
|
||||
for _ in range(DINGTALK_MAX_REMOTE_MEDIA_REDIRECTS + 1):
|
||||
resp = await self._http.get(current_url, follow_redirects=False)
|
||||
final_ok, final_err = validate_resolved_url(str(getattr(resp, "url", current_url)))
|
||||
if not final_ok:
|
||||
logger.warning(
|
||||
"DingTalk remote media redirect blocked ref={} final={} reason={}",
|
||||
media_ref,
|
||||
getattr(resp, "url", current_url),
|
||||
final_err,
|
||||
)
|
||||
return None, None
|
||||
if 300 <= resp.status_code < 400:
|
||||
next_url = self._next_remote_media_url(
|
||||
str(getattr(resp, "url", current_url)), resp.headers.get("location")
|
||||
)
|
||||
if not next_url:
|
||||
return None, None
|
||||
current_url = next_url
|
||||
continue
|
||||
if resp.status_code >= 400:
|
||||
logger.warning(
|
||||
"DingTalk media download failed status={} ref={}",
|
||||
resp.status_code,
|
||||
current_url,
|
||||
)
|
||||
return None, None
|
||||
if len(resp.content) > DINGTALK_MAX_REMOTE_MEDIA_BYTES:
|
||||
logger.warning(
|
||||
"DingTalk media download too large ref={} bytes>{}",
|
||||
current_url,
|
||||
DINGTALK_MAX_REMOTE_MEDIA_BYTES,
|
||||
)
|
||||
return None, None
|
||||
return resp.content, (resp.headers.get("content-type") or "")
|
||||
logger.warning("DingTalk media download exceeded redirect limit ref={}", media_ref)
|
||||
return None, None
|
||||
except httpx.TransportError as e:
|
||||
logger.error("DingTalk media download network error ref={} err={}", media_ref, e)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("DingTalk media download error ref={} err={}", media_ref, e)
|
||||
return None, None
|
||||
|
||||
async def _read_media_bytes(
|
||||
self,
|
||||
media_ref: str,
|
||||
@@ -323,26 +472,12 @@ class DingTalkChannel(BaseChannel):
|
||||
return None, None, None
|
||||
|
||||
if self._is_http_url(media_ref):
|
||||
if not self._http:
|
||||
return None, None, None
|
||||
try:
|
||||
resp = await self._http.get(media_ref, follow_redirects=True)
|
||||
if resp.status_code >= 400:
|
||||
logger.warning(
|
||||
"DingTalk media download failed status={} ref={}",
|
||||
resp.status_code,
|
||||
media_ref,
|
||||
)
|
||||
return None, None, None
|
||||
content_type = (resp.headers.get("content-type") or "").split(";")[0].strip()
|
||||
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
|
||||
return resp.content, filename, content_type or None
|
||||
except httpx.TransportError as e:
|
||||
logger.error("DingTalk media download network error ref={} err={}", media_ref, e)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("DingTalk media download error ref={} err={}", media_ref, e)
|
||||
data, raw_content_type = await self._fetch_remote_media_bytes(media_ref)
|
||||
if data is None:
|
||||
return None, None, None
|
||||
content_type = (raw_content_type or "").split(";")[0].strip()
|
||||
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
|
||||
return data, filename, content_type or None
|
||||
|
||||
try:
|
||||
if media_ref.startswith("file://"):
|
||||
@@ -435,8 +570,10 @@ class DingTalkChannel(BaseChannel):
|
||||
if resp.status_code != 200:
|
||||
logger.error("DingTalk send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500])
|
||||
return False
|
||||
try: result = resp.json()
|
||||
except Exception: result = {}
|
||||
try:
|
||||
result = resp.json()
|
||||
except Exception:
|
||||
result = {}
|
||||
errcode = result.get("errcode")
|
||||
if errcode not in (None, 0):
|
||||
logger.error("DingTalk send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500])
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
@@ -564,10 +565,8 @@ class DiscordChannel(BaseChannel):
|
||||
# Delayed working indicator (cosmetic — not tied to subagent lifecycle)
|
||||
async def _delayed_working_emoji() -> None:
|
||||
await asyncio.sleep(self.config.working_emoji_delay)
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await message.add_reaction(self.config.working_emoji)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._working_emoji_tasks[channel_id] = asyncio.create_task(_delayed_working_emoji())
|
||||
|
||||
@@ -771,10 +770,8 @@ class DiscordChannel(BaseChannel):
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _clear_reactions(self, chat_id: str) -> None:
|
||||
"""Remove all pending reactions after bot replies."""
|
||||
@@ -788,10 +785,8 @@ class DiscordChannel(BaseChannel):
|
||||
return
|
||||
bot_user = self._client.user if self._client else None
|
||||
for emoji in (self.config.read_receipt_emoji, self.config.working_emoji):
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await msg_obj.remove_reaction(emoji, bot_user)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _cancel_all_typing(self) -> None:
|
||||
"""Stop all typing tasks."""
|
||||
|
||||
@@ -6,6 +6,7 @@ import imaplib
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
from contextlib import suppress
|
||||
from datetime import date
|
||||
from email import policy
|
||||
from email.header import decode_header, make_header
|
||||
@@ -460,10 +461,8 @@ class EmailChannel(BaseChannel):
|
||||
if mark_seen:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
finally:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _collect_self_addresses(self) -> set[str]:
|
||||
"""Return normalized email addresses owned by this channel instance."""
|
||||
|
||||
@@ -9,6 +9,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -612,12 +613,11 @@ class FeishuChannel(BaseChannel):
|
||||
"""Callback: store reaction_id after background add-reaction completes."""
|
||||
if task.cancelled():
|
||||
return
|
||||
try:
|
||||
# Failures already logged by _on_background_task_done.
|
||||
with suppress(Exception):
|
||||
reaction_id = task.result()
|
||||
if reaction_id:
|
||||
self._reaction_ids[message_id] = reaction_id
|
||||
except Exception:
|
||||
pass # already logged by _on_background_task_done
|
||||
# Trim cache to prevent unbounded growth
|
||||
if len(self._reaction_ids) > 500:
|
||||
self._reaction_ids.pop(next(iter(self._reaction_ids)))
|
||||
|
||||
+25
-20
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -37,13 +38,6 @@ _BOOL_CAMEL_ALIASES: dict[str, str] = {
|
||||
"send_tool_hints": "sendToolHints",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RecentOutbound:
|
||||
fingerprint: str
|
||||
ts: float
|
||||
|
||||
|
||||
class ChannelManager:
|
||||
"""
|
||||
Manages chat channels and coordinates message routing.
|
||||
@@ -66,7 +60,7 @@ class ChannelManager:
|
||||
self._session_manager = session_manager
|
||||
self.channels: dict[str, BaseChannel] = {}
|
||||
self._dispatch_task: asyncio.Task | None = None
|
||||
self._recent_outbound: dict[tuple[str, str], _RecentOutbound] = {}
|
||||
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
|
||||
|
||||
self._init_channels()
|
||||
|
||||
@@ -228,10 +222,8 @@ class ChannelManager:
|
||||
# Stop dispatcher
|
||||
if self._dispatch_task:
|
||||
self._dispatch_task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._dispatch_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Stop all channels
|
||||
for name, channel in self.channels.items():
|
||||
@@ -247,17 +239,25 @@ class ChannelManager:
|
||||
return hashlib.sha1(normalized.encode("utf-8")).hexdigest() if normalized else ""
|
||||
|
||||
def _should_suppress_outbound(self, msg: OutboundMessage) -> bool:
|
||||
if msg.metadata.get("_progress"):
|
||||
metadata = msg.metadata or {}
|
||||
if metadata.get("_progress"):
|
||||
return False
|
||||
fingerprint = self._fingerprint_content(msg.content)
|
||||
if not fingerprint:
|
||||
return False
|
||||
key = (msg.channel, msg.chat_id)
|
||||
recent = self._recent_outbound.get(key)
|
||||
now = asyncio.get_running_loop().time()
|
||||
if recent and recent.fingerprint == fingerprint and now - recent.ts <= 8.0:
|
||||
return True
|
||||
self._recent_outbound[key] = _RecentOutbound(fingerprint=fingerprint, ts=now)
|
||||
|
||||
origin_message_id = metadata.get("origin_message_id")
|
||||
if isinstance(origin_message_id, str) and origin_message_id:
|
||||
key = (msg.channel, msg.chat_id, origin_message_id)
|
||||
if self._origin_reply_fingerprints.get(key) == fingerprint:
|
||||
return True
|
||||
self._origin_reply_fingerprints[key] = fingerprint
|
||||
|
||||
message_id = metadata.get("message_id")
|
||||
if isinstance(message_id, str) and message_id:
|
||||
key = (msg.channel, msg.chat_id, message_id)
|
||||
self._origin_reply_fingerprints[key] = fingerprint
|
||||
|
||||
return False
|
||||
|
||||
async def _dispatch_outbound(self) -> None:
|
||||
@@ -300,8 +300,13 @@ class ChannelManager:
|
||||
|
||||
channel = self.channels.get(msg.channel)
|
||||
if channel:
|
||||
# Duplicate suppression (non-streaming only)
|
||||
if not msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end") and not msg.metadata.get("_streamed"):
|
||||
# Duplicate suppression is scoped to a known source message
|
||||
# so repeated content from separate turns is still delivered.
|
||||
if (
|
||||
not msg.metadata.get("_stream_delta")
|
||||
and not msg.metadata.get("_stream_end")
|
||||
and not msg.metadata.get("_streamed")
|
||||
):
|
||||
if self._should_suppress_outbound(msg):
|
||||
logger.info("Suppressing duplicate outbound message to {}:{}", msg.channel, msg.chat_id)
|
||||
continue
|
||||
|
||||
+29
-16
@@ -5,6 +5,7 @@ import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypeAlias
|
||||
@@ -214,7 +215,7 @@ class MatrixConfig(Base):
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
||||
group_allow_from: list[str] = Field(default_factory=list)
|
||||
allow_room_mentions: bool = False,
|
||||
allow_room_mentions: bool = False
|
||||
streaming: bool = False
|
||||
|
||||
|
||||
@@ -251,11 +252,13 @@ class MatrixChannel(BaseChannel):
|
||||
self._server_upload_limit_bytes: int | None = None
|
||||
self._server_upload_limit_checked = False
|
||||
self._stream_bufs: dict[str, _StreamBuf] = {}
|
||||
self._started_at_ms: int = 0
|
||||
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start Matrix client and begin sync loop."""
|
||||
self._running = True
|
||||
self._started_at_ms = int(time.time() * 1000)
|
||||
_configure_nio_logging_bridge()
|
||||
|
||||
self.store_path = get_data_dir() / "matrix-store"
|
||||
@@ -341,10 +344,8 @@ class MatrixChannel(BaseChannel):
|
||||
timeout=self.config.sync_stop_grace_seconds)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
self._sync_task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._sync_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self.client:
|
||||
await self.client.close()
|
||||
|
||||
@@ -523,7 +524,7 @@ class MatrixChannel(BaseChannel):
|
||||
failures.append(fail)
|
||||
if failures:
|
||||
text = f"{text.rstrip()}\n{chr(10).join(failures)}" if text.strip() else "\n".join(failures)
|
||||
if text or not candidates:
|
||||
if text.strip():
|
||||
content = _build_matrix_text_content(text)
|
||||
if relates_to:
|
||||
content["m.relates_to"] = relates_to
|
||||
@@ -609,13 +610,11 @@ class MatrixChannel(BaseChannel):
|
||||
"""Best-effort typing indicator update."""
|
||||
if not self.client:
|
||||
return
|
||||
try:
|
||||
with suppress(Exception):
|
||||
response = await self.client.room_typing(room_id=room_id, typing_state=typing,
|
||||
timeout=TYPING_NOTICE_TIMEOUT_MS)
|
||||
if isinstance(response, RoomTypingError):
|
||||
logger.debug("Matrix typing failed for {}: {}", room_id, response)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _start_typing_keepalive(self, room_id: str) -> None:
|
||||
"""Start periodic typing refresh (spec-recommended keepalive)."""
|
||||
@@ -625,22 +624,18 @@ class MatrixChannel(BaseChannel):
|
||||
return
|
||||
|
||||
async def loop() -> None:
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
while self._running:
|
||||
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_MS / 1000)
|
||||
await self._set_typing(room_id, True)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._typing_tasks[room_id] = asyncio.create_task(loop())
|
||||
|
||||
async def _stop_typing_keepalive(self, room_id: str, *, clear_typing: bool) -> None:
|
||||
if task := self._typing_tasks.pop(room_id, None):
|
||||
task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if clear_typing:
|
||||
await self._set_typing(room_id, False)
|
||||
|
||||
@@ -674,6 +669,16 @@ class MatrixChannel(BaseChannel):
|
||||
return True
|
||||
return bool(self.config.allow_room_mentions and mentions.get("room") is True)
|
||||
|
||||
def _is_pre_startup_event(self, event: RoomMessage) -> bool:
|
||||
"""Skip events that landed in the timeline before this process started.
|
||||
|
||||
Matrix sync replays the room timeline on each startup/restart; without
|
||||
this filter old messages would be re-handled as if they were fresh
|
||||
(#3553).
|
||||
"""
|
||||
ts = getattr(event, "server_timestamp", None)
|
||||
return isinstance(ts, int) and ts < self._started_at_ms
|
||||
|
||||
def _should_process_message(self, room: MatrixRoom, event: RoomMessage) -> bool:
|
||||
"""Apply sender and room policy checks."""
|
||||
if not self.is_allowed(event.sender):
|
||||
@@ -858,7 +863,11 @@ class MatrixChannel(BaseChannel):
|
||||
return meta
|
||||
|
||||
async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
|
||||
if event.sender == self.config.user_id or not self._should_process_message(room, event):
|
||||
if (
|
||||
event.sender == self.config.user_id
|
||||
or self._is_pre_startup_event(event)
|
||||
or not self._should_process_message(room, event)
|
||||
):
|
||||
return
|
||||
await self._start_typing_keepalive(room.room_id)
|
||||
try:
|
||||
@@ -871,7 +880,11 @@ class MatrixChannel(BaseChannel):
|
||||
raise
|
||||
|
||||
async def _on_media_message(self, room: MatrixRoom, event: MatrixMediaEvent) -> None:
|
||||
if event.sender == self.config.user_id or not self._should_process_message(room, event):
|
||||
if (
|
||||
event.sender == self.config.user_id
|
||||
or self._is_pre_startup_event(event)
|
||||
or not self._should_process_message(room, event)
|
||||
):
|
||||
return
|
||||
attachment, marker = await self._fetch_media_attachment(room, event)
|
||||
parts: list[str] = []
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
from collections import deque
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
@@ -330,10 +331,8 @@ class MochatChannel(BaseChannel):
|
||||
await self._cancel_delay_timers()
|
||||
|
||||
if self._socket:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._socket.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket = None
|
||||
|
||||
if self._cursor_save_task:
|
||||
@@ -460,10 +459,8 @@ class MochatChannel(BaseChannel):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect Mochat websocket: {}", e)
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket = None
|
||||
return False
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from contextlib import contextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -712,10 +712,8 @@ class MSTeamsChannel(BaseChannel):
|
||||
os.replace(tmp_path, path)
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
try:
|
||||
with suppress(OSError):
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _save_refs_locked(self, *, prune: bool = True) -> None:
|
||||
"""Persist conversation references (caller must hold _refs_guard)."""
|
||||
|
||||
@@ -25,6 +25,7 @@ import os
|
||||
import re
|
||||
import time
|
||||
from collections import deque
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from urllib.parse import unquote, urlparse
|
||||
@@ -221,17 +222,13 @@ class QQChannel(BaseChannel):
|
||||
"""Stop bot and cleanup resources."""
|
||||
self._running = False
|
||||
if self._client:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
if self._http:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._http.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._http = None
|
||||
|
||||
logger.info("QQ bot stopped")
|
||||
@@ -683,7 +680,5 @@ class QQChannel(BaseChannel):
|
||||
finally:
|
||||
# Cleanup partial file
|
||||
if tmp_path is not None:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
@@ -462,10 +463,8 @@ class TelegramChannel(BaseChannel):
|
||||
if not msg.metadata.get("_progress", False):
|
||||
self._stop_typing(msg.chat_id)
|
||||
if reply_to_message_id := msg.metadata.get("message_id"):
|
||||
try:
|
||||
with suppress(ValueError):
|
||||
await self._remove_reaction(msg.chat_id, int(reply_to_message_id))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
chat_id = int(msg.chat_id)
|
||||
@@ -642,10 +641,8 @@ class TelegramChannel(BaseChannel):
|
||||
return
|
||||
self._stop_typing(chat_id)
|
||||
if reply_to_message_id := meta.get("message_id"):
|
||||
try:
|
||||
with suppress(ValueError):
|
||||
await self._remove_reaction(chat_id, int(reply_to_message_id))
|
||||
except ValueError:
|
||||
pass
|
||||
thread_kwargs = {}
|
||||
if message_thread_id := meta.get("message_thread_id"):
|
||||
thread_kwargs["message_thread_id"] = message_thread_id
|
||||
@@ -1162,11 +1159,10 @@ class TelegramChannel(BaseChannel):
|
||||
async def _typing_loop(self, chat_id: str) -> None:
|
||||
"""Repeatedly send 'typing' action until cancelled."""
|
||||
try:
|
||||
while self._app:
|
||||
await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing")
|
||||
await asyncio.sleep(4)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
with suppress(asyncio.CancelledError):
|
||||
while self._app:
|
||||
await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing")
|
||||
await asyncio.sleep(4)
|
||||
except Exception as e:
|
||||
logger.debug("Typing indicator stopped for {}: {}", chat_id, e)
|
||||
|
||||
@@ -1265,10 +1261,8 @@ class TelegramChannel(BaseChannel):
|
||||
button_label = query.data or ""
|
||||
await query.answer()
|
||||
if query.message:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await query.message.edit_reply_markup(reply_markup=None)
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Inline button tap from {}: {}", sender_id, button_label)
|
||||
self._start_typing(str(chat_id))
|
||||
await self._handle_message(
|
||||
|
||||
+12
-33
@@ -19,6 +19,7 @@ import re
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
@@ -211,7 +212,7 @@ class WeixinChannel(BaseChannel):
|
||||
|
||||
def _save_state(self) -> None:
|
||||
state_file = self._get_state_dir() / "account.json"
|
||||
try:
|
||||
with suppress(Exception):
|
||||
data = {
|
||||
"token": self._token,
|
||||
"get_updates_buf": self._get_updates_buf,
|
||||
@@ -220,8 +221,6 @@ class WeixinChannel(BaseChannel):
|
||||
"base_url": self.config.base_url,
|
||||
}
|
||||
state_file.write_text(json.dumps(data, ensure_ascii=False))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HTTP helpers (matches api.ts buildHeaders / apiFetch)
|
||||
@@ -576,10 +575,8 @@ class WeixinChannel(BaseChannel):
|
||||
# Process messages (WeixinMessage[] from types.ts)
|
||||
msgs: list[dict] = data.get("msgs", []) or []
|
||||
for msg in msgs:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._process_message(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Inbound message processing (matches inbound.ts + process-message.ts)
|
||||
@@ -932,10 +929,8 @@ class WeixinChannel(BaseChannel):
|
||||
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S)
|
||||
if stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._send_typing(user_id, typing_ticket, TYPING_STATUS_TYPING)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
pass
|
||||
|
||||
@@ -962,16 +957,12 @@ class WeixinChannel(BaseChannel):
|
||||
return
|
||||
|
||||
typing_ticket = ""
|
||||
try:
|
||||
with suppress(Exception):
|
||||
typing_ticket = await self._get_typing_ticket(msg.chat_id, ctx_token)
|
||||
except Exception:
|
||||
typing_ticket = ""
|
||||
|
||||
if typing_ticket:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
typing_keepalive_stop = asyncio.Event()
|
||||
typing_keepalive_task: asyncio.Task | None = None
|
||||
@@ -1043,16 +1034,12 @@ class WeixinChannel(BaseChannel):
|
||||
if typing_keepalive_task:
|
||||
typing_keepalive_stop.set()
|
||||
typing_keepalive_task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await typing_keepalive_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if typing_ticket and not is_progress:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
|
||||
"""Start typing indicator immediately when a message is received."""
|
||||
@@ -1076,10 +1063,8 @@ class WeixinChannel(BaseChannel):
|
||||
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S)
|
||||
if stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._send_typing(chat_id, ticket, TYPING_STATUS_TYPING)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
pass
|
||||
|
||||
@@ -1095,10 +1080,8 @@ class WeixinChannel(BaseChannel):
|
||||
if stop_event:
|
||||
stop_event.set()
|
||||
task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if not clear_remote:
|
||||
return
|
||||
entry = self._typing_tickets.get(chat_id)
|
||||
@@ -1339,13 +1322,11 @@ def _encrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
|
||||
pad_len = 16 - len(data) % 16
|
||||
padded = data + bytes([pad_len] * pad_len)
|
||||
|
||||
try:
|
||||
with suppress(ImportError):
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
return cipher.encrypt(padded)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
@@ -1371,13 +1352,11 @@ def _decrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
|
||||
|
||||
decrypted: bytes | None = None
|
||||
|
||||
try:
|
||||
with suppress(ImportError):
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(data)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if decrypted is None:
|
||||
try:
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
from contextlib import suppress
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
@@ -47,10 +48,8 @@ def _load_or_create_bridge_token(path: Path) -> str:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
token = secrets.token_urlsafe(32)
|
||||
path.write_text(token, encoding="utf-8")
|
||||
try:
|
||||
with suppress(OSError):
|
||||
path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return token
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user