fix(agent): scope subagent reply dedupe to origin message

Made-with: Cursor
This commit is contained in:
Xubin Ren
2026-05-01 11:47:24 +00:00
54 changed files with 2521 additions and 434 deletions
+162 -25
View File
@@ -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])
+4 -9
View File
@@ -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."""
+2 -3
View File
@@ -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."""
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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] = []
+3 -6
View File
@@ -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
+2 -4
View File
@@ -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)."""
+4 -9
View File
@@ -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
+8 -14
View File
@@ -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
View File
@@ -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:
+2 -3
View File
@@ -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