Merge remote-tracking branch 'origin/main' into hermes/hermes-6b48295e
This commit is contained in:
@@ -1545,6 +1545,13 @@ class SendResult:
|
||||
message_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
raw_response: Any = None
|
||||
# Adapter-specific metadata. Cross-layer contracts that affect delivery
|
||||
# semantics must be documented at the producer and consumer sites. Current
|
||||
# known contract: Telegram edit overflow partials set
|
||||
# raw_response["partial_overflow"] with delivered_chunks, total_chunks,
|
||||
# last_message_id, delivered_prefix, and continuation_message_ids so the
|
||||
# stream consumer can send the missing tail instead of marking a clipped
|
||||
# response complete.
|
||||
retryable: bool = False # True for transient connection errors — base will retry automatically
|
||||
# When the adapter had to split an oversized payload across multiple
|
||||
# platform messages (e.g. Telegram edit_message overflow split-and-deliver),
|
||||
|
||||
+1404
-286
File diff suppressed because it is too large
Load Diff
@@ -2348,10 +2348,15 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
except Exception as fmt_err:
|
||||
if "not modified" not in str(fmt_err).lower():
|
||||
logger.warning(
|
||||
"[%s] Overflow split: MarkdownV2 first-chunk edit "
|
||||
"failed, falling back to plain text: %s",
|
||||
self.name, fmt_err,
|
||||
)
|
||||
await self._bot.edit_message_text(
|
||||
chat_id=int(chat_id),
|
||||
message_id=int(message_id),
|
||||
text=first_chunk,
|
||||
text=_strip_mdv2(first_chunk),
|
||||
)
|
||||
else:
|
||||
await self._bot.edit_message_text(
|
||||
@@ -2379,6 +2384,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# are already correctly sized). Best-effort MarkdownV2 with plain
|
||||
# fallback, mirroring send().
|
||||
continuation_ids: list[str] = []
|
||||
delivered_chunks = [first_chunk]
|
||||
prev_id = message_id
|
||||
thread_id = self._metadata_thread_id(metadata)
|
||||
for chunk in chunks[1:]:
|
||||
@@ -2392,7 +2398,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
for use_markdown in (True, False) if finalize else (False,):
|
||||
try:
|
||||
text = self.format_message(chunk) if use_markdown else chunk
|
||||
if use_markdown:
|
||||
text = self.format_message(chunk)
|
||||
else:
|
||||
# Plain attempt: on finalize the MarkdownV2 attempt
|
||||
# failed, so degrade to clean stripped text, never
|
||||
# the raw chunk (raw ** / ``` markers would render
|
||||
# literally); streaming previews stay raw.
|
||||
text = _strip_mdv2(chunk) if finalize else chunk
|
||||
sent_msg = await self._bot.send_message(
|
||||
chat_id=int(chat_id),
|
||||
text=text,
|
||||
@@ -2418,7 +2431,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
sent_msg = await self._bot.send_message(
|
||||
chat_id=int(chat_id),
|
||||
text=chunk,
|
||||
text=_strip_mdv2(chunk) if finalize else chunk,
|
||||
**retry_thread_kwargs,
|
||||
**self._link_preview_kwargs(),
|
||||
**self._notification_kwargs(metadata),
|
||||
@@ -2442,17 +2455,37 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
break
|
||||
if sent_msg is None:
|
||||
# Continuation failed — the user has chunk 1 + however many
|
||||
# continuations succeeded. Report success with what we got
|
||||
# so the stream consumer knows the edit landed; the
|
||||
# remaining tail is lost on this attempt and the next
|
||||
# streaming tick may retry.
|
||||
# continuations succeeded, but NOT the full response. Do not
|
||||
# report success: the stream consumer treats a successful edit
|
||||
# as final delivery on got_done, which would suppress fallback
|
||||
# delivery and leave the Telegram topic clipped after the last
|
||||
# delivered chunk.
|
||||
logger.warning(
|
||||
"[%s] Overflow split: stopped at %d/%d chunks delivered",
|
||||
self.name, 1 + len(continuation_ids), len(chunks),
|
||||
)
|
||||
break
|
||||
delivered_prefix = "".join(
|
||||
re.sub(r" \(\d+/\d+\)$", "", delivered)
|
||||
for delivered in delivered_chunks
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
message_id=prev_id,
|
||||
error="overflow_continuation_failed",
|
||||
retryable=True,
|
||||
raw_response={
|
||||
"partial_overflow": True,
|
||||
"delivered_chunks": 1 + len(continuation_ids),
|
||||
"total_chunks": len(chunks),
|
||||
"last_message_id": prev_id,
|
||||
"delivered_prefix": delivered_prefix,
|
||||
"continuation_message_ids": tuple(continuation_ids),
|
||||
},
|
||||
continuation_message_ids=tuple(continuation_ids),
|
||||
)
|
||||
new_id = str(getattr(sent_msg, "message_id", "")) or prev_id
|
||||
continuation_ids.append(new_id)
|
||||
delivered_chunks.append(chunk)
|
||||
prev_id = new_id
|
||||
|
||||
last_id = continuation_ids[-1] if continuation_ids else message_id
|
||||
@@ -3804,6 +3837,33 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return error
|
||||
|
||||
def _telegram_media_too_large_note(self, label: str, file_size: Any, max_bytes: int) -> str:
|
||||
limit_mb = max(1, max_bytes // (1024 * 1024))
|
||||
try:
|
||||
size_mb = int(file_size or 0) / (1024 * 1024)
|
||||
size_text = f"{size_mb:.1f} MB"
|
||||
except (TypeError, ValueError):
|
||||
size_text = "unknown size"
|
||||
return (
|
||||
f"[Telegram {label} skipped: file size {size_text} exceeds the "
|
||||
f"{limit_mb} MB limit. Ask the user to send a shorter voice note "
|
||||
"or a smaller audio file.]"
|
||||
)
|
||||
|
||||
def _telegram_media_size_allowed(self, source: Any, label: str) -> tuple[bool, Optional[str]]:
|
||||
"""Validate Telegram media size before downloading into memory."""
|
||||
max_bytes = int(getattr(self, "_max_doc_bytes", 20 * 1024 * 1024) or 20 * 1024 * 1024)
|
||||
file_size = getattr(source, "file_size", None)
|
||||
try:
|
||||
size = int(file_size or 0)
|
||||
except (TypeError, ValueError):
|
||||
size = 0
|
||||
if size <= 0:
|
||||
return True, None
|
||||
if size <= max_bytes:
|
||||
return True, None
|
||||
return False, self._telegram_media_too_large_note(label, size, max_bytes)
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -5569,6 +5629,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# Download voice/audio messages to cache for STT transcription
|
||||
if msg.voice:
|
||||
try:
|
||||
allowed, note = self._telegram_media_size_allowed(msg.voice, "voice message")
|
||||
if not allowed:
|
||||
event.text = self._append_observed_note(event.text, note or "")
|
||||
logger.info("[Telegram] Skipped oversized user voice (size=%s)", getattr(msg.voice, "file_size", None))
|
||||
await self.handle_message(event)
|
||||
return
|
||||
file_obj = await msg.voice.get_file()
|
||||
audio_bytes = await file_obj.download_as_bytearray()
|
||||
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".ogg")
|
||||
@@ -5579,6 +5645,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
logger.warning("[Telegram] Failed to cache voice: %s", e, exc_info=True)
|
||||
elif msg.audio:
|
||||
try:
|
||||
allowed, note = self._telegram_media_size_allowed(msg.audio, "audio file")
|
||||
if not allowed:
|
||||
event.text = self._append_observed_note(event.text, note or "")
|
||||
logger.info("[Telegram] Skipped oversized user audio (size=%s)", getattr(msg.audio, "file_size", None))
|
||||
await self.handle_message(event)
|
||||
return
|
||||
file_obj = await msg.audio.get_file()
|
||||
audio_bytes = await file_obj.download_as_bytearray()
|
||||
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".mp3")
|
||||
|
||||
@@ -190,6 +190,22 @@ from gateway.platforms.base import (
|
||||
)
|
||||
|
||||
|
||||
def _file_content_hash(path: Path) -> str:
|
||||
"""Return the first 16 hex chars of the SHA-256 of *path*'s contents.
|
||||
|
||||
Used for the bridge staleness handshake: bridge.js reports its own
|
||||
source hash in ``/health`` (``scriptHash``), and the adapter compares
|
||||
it against the hash of bridge.js currently on disk. A mismatch means
|
||||
a long-lived bridge process is serving code from before an update.
|
||||
Returns ``""`` when the file can't be read.
|
||||
"""
|
||||
import hashlib
|
||||
try:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def check_whatsapp_requirements() -> bool:
|
||||
"""
|
||||
Check if WhatsApp dependencies are available.
|
||||
@@ -372,9 +388,21 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
logger.warning("[%s] Could not acquire session lock (non-fatal): %s", self.name, e)
|
||||
|
||||
try:
|
||||
# Auto-install npm dependencies if node_modules doesn't exist
|
||||
# Auto-install npm dependencies when node_modules is missing OR
|
||||
# package.json changed since the last install (e.g. after
|
||||
# `hermes update` bumps the Baileys pin). The stamp file records
|
||||
# the package.json hash of the last successful install.
|
||||
bridge_dir = bridge_path.parent
|
||||
if not (bridge_dir / "node_modules").exists():
|
||||
_pkg_json = bridge_dir / "package.json"
|
||||
_dep_stamp = bridge_dir / "node_modules" / ".hermes-pkg-hash"
|
||||
_pkg_hash = _file_content_hash(_pkg_json)
|
||||
_deps_fresh = False
|
||||
if (bridge_dir / "node_modules").exists():
|
||||
try:
|
||||
_deps_fresh = (_dep_stamp.read_text().strip() == _pkg_hash) and bool(_pkg_hash)
|
||||
except OSError:
|
||||
_deps_fresh = False
|
||||
if not _deps_fresh:
|
||||
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
|
||||
# Resolve npm path so Windows can execute the .cmd shim.
|
||||
# shutil.which honours PATHEXT; on POSIX it returns the
|
||||
@@ -395,6 +423,11 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
print(f"[{self.name}] npm install failed: {install_result.stderr}")
|
||||
return False
|
||||
print(f"[{self.name}] Dependencies installed")
|
||||
if _pkg_hash:
|
||||
try:
|
||||
_dep_stamp.write_text(_pkg_hash)
|
||||
except OSError:
|
||||
pass # Stamp is an optimization; install still succeeded
|
||||
except Exception as e:
|
||||
print(f"[{self.name}] Failed to install dependencies: {e}")
|
||||
return False
|
||||
@@ -414,12 +447,28 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
data = await resp.json()
|
||||
bridge_status = data.get("status", "unknown")
|
||||
if bridge_status == "connected":
|
||||
print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
|
||||
self._mark_connected()
|
||||
self._bridge_process = None # Not managed by us
|
||||
self._http_session = aiohttp.ClientSession()
|
||||
self._poll_task = asyncio.create_task(self._poll_messages())
|
||||
return True
|
||||
# Staleness handshake: only reuse a running
|
||||
# bridge if it is serving the same bridge.js
|
||||
# that is on disk right now. A long-lived
|
||||
# bridge survives gateway restarts AND
|
||||
# `hermes update`, so without this check it
|
||||
# keeps serving pre-update code forever
|
||||
# (e.g. no inbound media download). Old
|
||||
# bridges that don't report scriptHash are
|
||||
# treated as stale by definition.
|
||||
running_hash = data.get("scriptHash", "")
|
||||
disk_hash = _file_content_hash(bridge_path)
|
||||
if running_hash and disk_hash and running_hash == disk_hash:
|
||||
print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
|
||||
self._mark_connected()
|
||||
self._bridge_process = None # Not managed by us
|
||||
self._http_session = aiohttp.ClientSession()
|
||||
self._poll_task = asyncio.create_task(self._poll_messages())
|
||||
return True
|
||||
print(
|
||||
f"[{self.name}] Running bridge is stale "
|
||||
f"(running={running_hash or 'unversioned'}, disk={disk_hash}), restarting"
|
||||
)
|
||||
else:
|
||||
print(f"[{self.name}] Bridge found but not connected (status: {bridge_status}), restarting")
|
||||
except Exception:
|
||||
@@ -444,6 +493,18 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
bridge_env = os.environ.copy()
|
||||
if self._reply_prefix is not None:
|
||||
bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix
|
||||
# Pass the profile-aware cache directories so the bridge writes
|
||||
# media where the Python side reads it. Without these the bridge
|
||||
# hardcodes ~/.hermes/{image,audio,document}_cache, which diverges
|
||||
# under HERMES_HOME overrides, profiles, and the new cache/ layout.
|
||||
from gateway.platforms.base import (
|
||||
get_audio_cache_dir as _get_audio_dir,
|
||||
get_document_cache_dir as _get_doc_dir,
|
||||
get_image_cache_dir as _get_img_dir,
|
||||
)
|
||||
bridge_env["HERMES_IMAGE_CACHE_DIR"] = str(_get_img_dir())
|
||||
bridge_env["HERMES_AUDIO_CACHE_DIR"] = str(_get_audio_dir())
|
||||
bridge_env["HERMES_DOCUMENT_CACHE_DIR"] = str(_get_doc_dir())
|
||||
|
||||
self._bridge_process = subprocess.Popen(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user