opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config); Ink stays the default and the shipping path is untouched. - _resolve_tui_engine() (env > config > ink); refuses opentui on Windows/Termux (no Bun) -> falls back to ink with a notice. - _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step). - _bun_bin() with HERMES_BUN override. - Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host must not bootstrap Node). - Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun is JSC; the V8 flag errors/ignores). Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI -> real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
@@ -1,434 +0,0 @@
|
||||
"""User-authorization methods for ``GatewayRunner``.
|
||||
|
||||
Extracted from ``gateway/run.py`` as part of the god-file decomposition campaign
|
||||
(``~/.hermes/plans/god-file-decomposition.md``, Phase 3 mechanical mixin lifts).
|
||||
This mixin holds the inbound-message authorization cluster: whether a user/chat
|
||||
is allowed to talk to the agent, the per-adapter DM policy, and the
|
||||
unauthorized-DM behavior.
|
||||
|
||||
Behavior-neutral: every method is lifted verbatim from ``GatewayRunner``.
|
||||
``self.*`` calls resolve unchanged via the MRO. Neutral dependencies import at
|
||||
module top; the module-level ``logger`` is imported lazily inside the one method
|
||||
that uses it (``from gateway.run import logger`` resolves at call time, when
|
||||
``gateway.run`` is fully loaded) so this module never imports ``gateway.run`` at
|
||||
import time -> no import cycle. The lazy import preserves the exact logger name
|
||||
(``"gateway.run"``) so log records are unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.session import SessionSource
|
||||
from gateway.whatsapp_identity import (
|
||||
expand_whatsapp_aliases as _expand_whatsapp_auth_aliases,
|
||||
normalize_whatsapp_identifier as _normalize_whatsapp_identifier,
|
||||
)
|
||||
|
||||
|
||||
class GatewayAuthorizationMixin:
|
||||
"""User/chat authorization methods for ``GatewayRunner``."""
|
||||
|
||||
def _adapter_enforces_own_access_policy(self, platform: Optional[Platform]) -> bool:
|
||||
"""Whether the adapter for *platform* gates access at intake itself.
|
||||
|
||||
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
|
||||
such as WeCom, Weixin, Yuanbao, QQBot, and WhatsApp evaluate their
|
||||
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
|
||||
message is dispatched to the gateway, so a message that reaches
|
||||
``_is_user_authorized`` has already been authorized by the adapter.
|
||||
Defaults to ``False`` when the adapter is unknown or doesn't expose
|
||||
the flag.
|
||||
"""
|
||||
if not platform:
|
||||
return False
|
||||
# Some test helpers build a bare GatewayRunner via object.__new__ and
|
||||
# never set ``adapters``; treat a missing/empty map as "no adapter"
|
||||
# rather than raising (see pitfalls.md #17).
|
||||
adapters = getattr(self, "adapters", None)
|
||||
if not adapters:
|
||||
return False
|
||||
adapter = adapters.get(platform)
|
||||
if adapter is None:
|
||||
return False
|
||||
return bool(getattr(adapter, "enforces_own_access_policy", False))
|
||||
|
||||
def _adapter_dm_policy(self, platform: Optional[Platform]) -> str:
|
||||
"""Best-effort read of an own-policy adapter's effective DM policy.
|
||||
|
||||
Returns the lowercased ``dm_policy`` (``"open"`` / ``"allowlist"`` /
|
||||
``"disabled"`` / ``"pairing"``) for *platform*, or ``""`` when unknown.
|
||||
Prefers the live adapter's resolved ``_dm_policy`` — which already folds
|
||||
in both ``config.extra`` and the ``<PLATFORM>_DM_POLICY`` env var (the
|
||||
env var is not always bridged back into ``config.extra``) — and falls
|
||||
back to ``config.extra`` for bare runners built without a live adapter.
|
||||
|
||||
Used by ``_is_user_authorized`` to carve ``dm_policy: pairing`` out of
|
||||
the adapter-trust shortcut: in pairing mode the adapter forwards the DM
|
||||
so the gateway can run its pairing handshake, so "reached the gateway"
|
||||
must not be read as "authorized".
|
||||
"""
|
||||
if not platform:
|
||||
return ""
|
||||
adapters = getattr(self, "adapters", None) or {}
|
||||
adapter = adapters.get(platform)
|
||||
policy = getattr(adapter, "_dm_policy", None) if adapter is not None else None
|
||||
if policy is None:
|
||||
config = getattr(self, "config", None)
|
||||
platform_cfg = (
|
||||
config.platforms.get(platform)
|
||||
if config is not None and hasattr(config, "platforms")
|
||||
else None
|
||||
)
|
||||
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
|
||||
if isinstance(extra, dict):
|
||||
policy = extra.get("dm_policy")
|
||||
return str(policy or "").strip().lower()
|
||||
|
||||
def _is_user_authorized(self, source: SessionSource) -> bool:
|
||||
"""
|
||||
Check if a user is authorized to use the bot.
|
||||
|
||||
Checks in order:
|
||||
1. Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||||
2. Environment variable allowlists (TELEGRAM_ALLOWED_USERS, etc.)
|
||||
3. DM pairing approved list
|
||||
4. Global allow-all (GATEWAY_ALLOW_ALL_USERS=true)
|
||||
5. Default: deny
|
||||
"""
|
||||
from gateway.run import logger
|
||||
# Home Assistant events are system-generated (state changes), not
|
||||
# user-initiated messages. The HASS_TOKEN already authenticates the
|
||||
# connection, so HA events are always authorized.
|
||||
# Webhook events are authenticated via HMAC signature validation in
|
||||
# the adapter itself — no user allowlist applies.
|
||||
if source.platform in {Platform.HOMEASSISTANT, Platform.WEBHOOK}:
|
||||
return True
|
||||
|
||||
user_id = source.user_id
|
||||
|
||||
# Telegram (and similar) authorize entire group/forum/channel chats
|
||||
# by chat ID via TELEGRAM_GROUP_ALLOWED_CHATS / QQ_GROUP_ALLOWED_USERS.
|
||||
# That allowlist is chat-scoped, so it must work even when
|
||||
# source.user_id is None — Telegram emits anonymous-admin posts,
|
||||
# sender_chat traffic, and channel broadcasts with no `from_user`,
|
||||
# and an operator who explicitly listed the chat expects those to
|
||||
# be honored. Run this check before the no-user-id guard below so
|
||||
# documented behavior matches reality
|
||||
# (website/docs/reference/environment-variables.md,
|
||||
# website/docs/user-guide/messaging/telegram.md).
|
||||
if source.chat_type in {"group", "forum", "channel"} and source.chat_id:
|
||||
chat_allowlist_env = {
|
||||
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS",
|
||||
Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS",
|
||||
}.get(source.platform, "")
|
||||
if chat_allowlist_env:
|
||||
raw_chat_allowlist = os.getenv(chat_allowlist_env, "").strip()
|
||||
if raw_chat_allowlist:
|
||||
allowed_group_ids = {
|
||||
cid.strip()
|
||||
for cid in raw_chat_allowlist.split(",")
|
||||
if cid.strip()
|
||||
}
|
||||
if "*" in allowed_group_ids or source.chat_id in allowed_group_ids:
|
||||
return True
|
||||
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
platform_env_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
|
||||
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
|
||||
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
|
||||
Platform.SLACK: "SLACK_ALLOWED_USERS",
|
||||
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
|
||||
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
|
||||
Platform.SMS: "SMS_ALLOWED_USERS",
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
|
||||
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
|
||||
Platform.WECOM: "WECOM_ALLOWED_USERS",
|
||||
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
|
||||
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
|
||||
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
|
||||
Platform.QQBOT: "QQ_ALLOWED_USERS",
|
||||
Platform.YUANBAO: "YUANBAO_ALLOWED_USERS",
|
||||
}
|
||||
platform_group_user_env_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS",
|
||||
}
|
||||
platform_group_chat_env_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS",
|
||||
Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS",
|
||||
}
|
||||
platform_allow_all_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
|
||||
Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS",
|
||||
Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS",
|
||||
Platform.SLACK: "SLACK_ALLOW_ALL_USERS",
|
||||
Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS",
|
||||
Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS",
|
||||
Platform.SMS: "SMS_ALLOW_ALL_USERS",
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
|
||||
Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS",
|
||||
Platform.WECOM: "WECOM_ALLOW_ALL_USERS",
|
||||
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS",
|
||||
Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS",
|
||||
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS",
|
||||
Platform.QQBOT: "QQ_ALLOW_ALL_USERS",
|
||||
Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS",
|
||||
}
|
||||
# Bots admitted by {PLATFORM}_ALLOW_BOTS bypass the human allowlist (#4466).
|
||||
platform_allow_bots_map = {
|
||||
Platform.DISCORD: "DISCORD_ALLOW_BOTS",
|
||||
Platform.FEISHU: "FEISHU_ALLOW_BOTS",
|
||||
}
|
||||
|
||||
# Plugin platforms: check the registry for auth env var names
|
||||
if source.platform not in platform_env_map:
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
entry = platform_registry.get(source.platform.value)
|
||||
if entry:
|
||||
if entry.allowed_users_env:
|
||||
platform_env_map[source.platform] = entry.allowed_users_env
|
||||
if entry.allow_all_env:
|
||||
platform_allow_all_map[source.platform] = entry.allow_all_env
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||||
platform_allow_all_var = platform_allow_all_map.get(source.platform, "")
|
||||
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in {"true", "1", "yes"}:
|
||||
return True
|
||||
|
||||
# Adapter-verified role auth: the Discord adapter already confirmed the
|
||||
# user holds a role in DISCORD_ALLOWED_ROLES before dispatching the message.
|
||||
# Compare with ``is True`` so the real bool field authorizes while a
|
||||
# MagicMock source (test fixtures using ``object.__new__`` runners with
|
||||
# mock sources) does not auto-truthy through this gate (see pitfall #13).
|
||||
if getattr(source, "role_authorized", False) is True:
|
||||
return True
|
||||
|
||||
if getattr(source, "is_bot", False):
|
||||
allow_bots_var = platform_allow_bots_map.get(source.platform)
|
||||
if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in {"mentions", "all"}:
|
||||
return True
|
||||
|
||||
# Check pairing store (always checked, regardless of allowlists)
|
||||
platform_name = source.platform.value if source.platform else ""
|
||||
if self.pairing_store.is_approved(platform_name, user_id):
|
||||
return True
|
||||
|
||||
# Check platform-specific and global allowlists
|
||||
platform_allowlist = os.getenv(platform_env_map.get(source.platform, ""), "").strip()
|
||||
group_user_allowlist = ""
|
||||
group_chat_allowlist = ""
|
||||
if source.chat_type in {"group", "forum"}:
|
||||
group_user_allowlist = os.getenv(platform_group_user_env_map.get(source.platform, ""), "").strip()
|
||||
group_chat_allowlist = os.getenv(platform_group_chat_env_map.get(source.platform, ""), "").strip()
|
||||
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
|
||||
|
||||
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
|
||||
# No env allowlists configured. Adapters that own their own
|
||||
# config-driven access policy (dm_policy / group_policy /
|
||||
# allow_from / group_allow_from) already gated this message at
|
||||
# intake — it would not have reached the gateway otherwise — so
|
||||
# honor that decision instead of falling through to the
|
||||
# env-only default-deny below, which would silently break
|
||||
# `dm_policy: open` and config-only allowlists. (#34515)
|
||||
if self._adapter_enforces_own_access_policy(source.platform):
|
||||
# Exception: `dm_policy: pairing` does NOT authorize at intake.
|
||||
# The adapter forwards the DM precisely so the gateway can run
|
||||
# its pairing handshake (issue a code, consult the pairing
|
||||
# store). The pairing-store approval check above already ran and
|
||||
# returned False for this sender, so blanket-trusting the
|
||||
# adapter here would silently turn pairing mode into open
|
||||
# access. Fall through to default-deny so the unpaired sender is
|
||||
# offered a pairing code instead. (Pairing is DM-only; group
|
||||
# traffic keeps the adapter-trust path.)
|
||||
if not (
|
||||
source.chat_type == "dm"
|
||||
and self._adapter_dm_policy(source.platform) == "pairing"
|
||||
):
|
||||
return True
|
||||
# No allowlists configured -- check global allow-all flag
|
||||
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
|
||||
|
||||
# Telegram can optionally authorize group traffic by chat ID.
|
||||
# Keep this separate from TELEGRAM_GROUP_ALLOWED_USERS, which gates
|
||||
# the sender user ID for group/forum messages.
|
||||
if group_chat_allowlist and source.chat_type in {"group", "forum"} and source.chat_id:
|
||||
allowed_group_ids = {
|
||||
chat_id.strip() for chat_id in group_chat_allowlist.split(",") if chat_id.strip()
|
||||
}
|
||||
if "*" in allowed_group_ids or source.chat_id in allowed_group_ids:
|
||||
return True
|
||||
|
||||
# Backward-compat shim for #15027: prior to PR #17686,
|
||||
# TELEGRAM_GROUP_ALLOWED_USERS was (mis)used as a chat-ID allowlist.
|
||||
# Values starting with "-" are Telegram chat IDs, not user IDs, so if
|
||||
# users still have those in TELEGRAM_GROUP_ALLOWED_USERS we honor them
|
||||
# as chat IDs and warn once. The correct var is now
|
||||
# TELEGRAM_GROUP_ALLOWED_CHATS.
|
||||
if (
|
||||
source.platform == Platform.TELEGRAM
|
||||
and group_user_allowlist
|
||||
and source.chat_type in {"group", "forum"}
|
||||
and source.chat_id
|
||||
):
|
||||
legacy_chat_ids = {
|
||||
v.strip()
|
||||
for v in group_user_allowlist.split(",")
|
||||
if v.strip().startswith("-")
|
||||
}
|
||||
if legacy_chat_ids:
|
||||
if not getattr(self, "_warned_telegram_group_users_legacy", False):
|
||||
logger.warning(
|
||||
"TELEGRAM_GROUP_ALLOWED_USERS contains chat-ID-shaped values "
|
||||
"(%s). Treating them as chat IDs for backward compatibility. "
|
||||
"Move chat IDs to TELEGRAM_GROUP_ALLOWED_CHATS — the _USERS var "
|
||||
"is now for sender user IDs.",
|
||||
",".join(sorted(legacy_chat_ids)),
|
||||
)
|
||||
self._warned_telegram_group_users_legacy = True
|
||||
if source.chat_id in legacy_chat_ids:
|
||||
return True
|
||||
|
||||
# Check if user is in any allowlist. In group/forum chats,
|
||||
# TELEGRAM_GROUP_ALLOWED_USERS is the scoped allowlist and should not
|
||||
# imply DM access; TELEGRAM_ALLOWED_USERS remains the platform-wide
|
||||
# allowlist and still works everywhere for backward compatibility.
|
||||
allowed_ids = set()
|
||||
if platform_allowlist:
|
||||
allowed_ids.update(uid.strip() for uid in platform_allowlist.split(",") if uid.strip())
|
||||
if group_user_allowlist:
|
||||
allowed_ids.update(uid.strip() for uid in group_user_allowlist.split(",") if uid.strip())
|
||||
if global_allowlist:
|
||||
allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip())
|
||||
|
||||
# "*" in any allowlist means allow everyone (consistent with
|
||||
# SIGNAL_GROUP_ALLOWED_USERS precedent)
|
||||
if "*" in allowed_ids:
|
||||
return True
|
||||
|
||||
check_ids = {user_id}
|
||||
if "@" in user_id:
|
||||
check_ids.add(user_id.split("@")[0])
|
||||
|
||||
# WhatsApp: resolve phone↔LID aliases from bridge session mapping files
|
||||
if source.platform == Platform.WHATSAPP:
|
||||
normalized_allowed_ids = set()
|
||||
for allowed_id in allowed_ids:
|
||||
normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id))
|
||||
if normalized_allowed_ids:
|
||||
allowed_ids = normalized_allowed_ids
|
||||
|
||||
check_ids.update(_expand_whatsapp_auth_aliases(user_id))
|
||||
normalized_user_id = _normalize_whatsapp_identifier(user_id)
|
||||
if normalized_user_id:
|
||||
check_ids.add(normalized_user_id)
|
||||
|
||||
# SimpleX: SIMPLEX_ALLOWED_USERS accepts either the numeric contactId
|
||||
# or the contact's display name. The adapter sets user_id=contactId for
|
||||
# stability across renames, but the SimpleX UI never surfaces the
|
||||
# numeric id — operators only see display names, so that's what they
|
||||
# naturally put in the env var. Match both so the allowlist works
|
||||
# regardless of which form was chosen.
|
||||
# Plugin platform: compare by value since Platform.SIMPLEX is not a
|
||||
# hardcoded enum member (it's a dynamic plugin platform).
|
||||
if (
|
||||
source.platform is not None
|
||||
and source.platform.value == "simplex"
|
||||
and source.user_name
|
||||
):
|
||||
check_ids.add(source.user_name)
|
||||
|
||||
return bool(check_ids & allowed_ids)
|
||||
|
||||
def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:
|
||||
"""Return how unauthorized DMs should be handled for a platform.
|
||||
|
||||
Resolution order:
|
||||
1. Explicit per-platform ``unauthorized_dm_behavior`` in config — always wins.
|
||||
2. Explicit global ``unauthorized_dm_behavior`` in config — wins when no per-platform.
|
||||
3. When an allowlist (``PLATFORM_ALLOWED_USERS``,
|
||||
``PLATFORM_GROUP_ALLOWED_USERS`` / ``PLATFORM_GROUP_ALLOWED_CHATS``,
|
||||
or ``GATEWAY_ALLOWED_USERS``) is configured, default to ``"ignore"`` —
|
||||
the allowlist signals that the owner has deliberately restricted
|
||||
access; spamming unknown contacts with pairing codes is both noisy
|
||||
and a potential info-leak. (#9337)
|
||||
4. No allowlist and no explicit config → ``"pair"`` (open-gateway default).
|
||||
"""
|
||||
config = getattr(self, "config", None)
|
||||
|
||||
# Check for an explicit per-platform override first.
|
||||
if config and hasattr(config, "get_unauthorized_dm_behavior") and platform:
|
||||
platform_cfg = config.platforms.get(platform) if hasattr(config, "platforms") else None
|
||||
if platform_cfg and "unauthorized_dm_behavior" in getattr(platform_cfg, "extra", {}):
|
||||
# Operator explicitly configured behavior for this platform — respect it.
|
||||
return config.get_unauthorized_dm_behavior(platform)
|
||||
|
||||
# Check for an explicit global config override.
|
||||
if config and hasattr(config, "unauthorized_dm_behavior"):
|
||||
if config.unauthorized_dm_behavior != "pair": # non-default → explicit override
|
||||
return config.unauthorized_dm_behavior
|
||||
|
||||
# Config-driven dm_policy (WeCom / Weixin / Yuanbao / QQBot). An
|
||||
# allowlist or disabled DM policy means the operator restricted access,
|
||||
# so unauthorized DMs should be dropped silently rather than answered
|
||||
# with a pairing code. An explicit pairing policy opts back into codes.
|
||||
if platform and config and hasattr(config, "platforms"):
|
||||
platform_cfg = config.platforms.get(platform)
|
||||
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
|
||||
if isinstance(extra, dict):
|
||||
dm_policy = str(extra.get("dm_policy") or "").strip().lower()
|
||||
if dm_policy == "pairing":
|
||||
return "pair"
|
||||
if dm_policy in {"allowlist", "disabled"}:
|
||||
return "ignore"
|
||||
|
||||
# No explicit override. Fall back to allowlist-aware default:
|
||||
# if any allowlist is configured for this platform, silently drop
|
||||
# unauthorized messages instead of sending pairing codes.
|
||||
if platform:
|
||||
platform_env_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
|
||||
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
|
||||
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
|
||||
Platform.SLACK: "SLACK_ALLOWED_USERS",
|
||||
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
|
||||
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
|
||||
Platform.SMS: "SMS_ALLOWED_USERS",
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
|
||||
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
|
||||
Platform.WECOM: "WECOM_ALLOWED_USERS",
|
||||
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
|
||||
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
|
||||
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
|
||||
Platform.QQBOT: "QQ_ALLOWED_USERS",
|
||||
}
|
||||
platform_group_env_map = {
|
||||
Platform.TELEGRAM: (
|
||||
"TELEGRAM_GROUP_ALLOWED_USERS",
|
||||
"TELEGRAM_GROUP_ALLOWED_CHATS",
|
||||
),
|
||||
Platform.QQBOT: ("QQ_GROUP_ALLOWED_USERS",),
|
||||
}
|
||||
if os.getenv(platform_env_map.get(platform, ""), "").strip():
|
||||
return "ignore"
|
||||
for env_key in platform_group_env_map.get(platform, ()):
|
||||
if os.getenv(env_key, "").strip():
|
||||
return "ignore"
|
||||
|
||||
if os.getenv("GATEWAY_ALLOWED_USERS", "").strip():
|
||||
return "ignore"
|
||||
|
||||
return "pair"
|
||||
+7
-83
@@ -56,42 +56,6 @@ def _coerce_int(value: Any, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_optional_positive_int(value: Any, key: str) -> Optional[int]:
|
||||
"""Coerce an optional positive integer config value.
|
||||
|
||||
``None``/0/negative disable the setting. Malformed values are ignored with
|
||||
a warning so a typo never prevents the gateway from starting.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
logger.warning(
|
||||
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
|
||||
key,
|
||||
value,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
if isinstance(value, float):
|
||||
if not value.is_integer():
|
||||
raise ValueError(value)
|
||||
parsed = int(value)
|
||||
elif isinstance(value, str):
|
||||
parsed = int(value.strip(), 10)
|
||||
else:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
|
||||
key,
|
||||
value,
|
||||
)
|
||||
return None
|
||||
if parsed <= 0:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
|
||||
"""Normalize unauthorized DM behavior to a supported value."""
|
||||
if isinstance(value, str):
|
||||
@@ -531,7 +495,6 @@ class GatewayConfig:
|
||||
# Session isolation in shared chats
|
||||
group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available
|
||||
thread_sessions_per_user: bool = False # When False (default), threads are shared across all participants
|
||||
max_concurrent_sessions: Optional[int] = None # Positive int caps simultaneous active chat sessions
|
||||
|
||||
# Unauthorized DM policy
|
||||
unauthorized_dm_behavior: str = "pair" # "pair" or "ignore"
|
||||
@@ -637,7 +600,6 @@ class GatewayConfig:
|
||||
"stt_enabled": self.stt_enabled,
|
||||
"group_sessions_per_user": self.group_sessions_per_user,
|
||||
"thread_sessions_per_user": self.thread_sessions_per_user,
|
||||
"max_concurrent_sessions": self.max_concurrent_sessions,
|
||||
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
|
||||
"streaming": self.streaming.to_dict(),
|
||||
"session_store_max_age_days": self.session_store_max_age_days,
|
||||
@@ -683,17 +645,6 @@ class GatewayConfig:
|
||||
|
||||
group_sessions_per_user = data.get("group_sessions_per_user")
|
||||
thread_sessions_per_user = data.get("thread_sessions_per_user")
|
||||
nested_gateway = data.get("gateway") if isinstance(data.get("gateway"), dict) else {}
|
||||
if "max_concurrent_sessions" in data:
|
||||
max_concurrent_raw = data.get("max_concurrent_sessions")
|
||||
max_concurrent_key = "max_concurrent_sessions"
|
||||
else:
|
||||
max_concurrent_raw = nested_gateway.get("max_concurrent_sessions")
|
||||
max_concurrent_key = "gateway.max_concurrent_sessions"
|
||||
max_concurrent_sessions = _coerce_optional_positive_int(
|
||||
max_concurrent_raw,
|
||||
max_concurrent_key,
|
||||
)
|
||||
unauthorized_dm_behavior = _normalize_unauthorized_dm_behavior(
|
||||
data.get("unauthorized_dm_behavior"),
|
||||
"pair",
|
||||
@@ -720,7 +671,6 @@ class GatewayConfig:
|
||||
stt_enabled=_coerce_bool(stt_enabled, True),
|
||||
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
|
||||
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
|
||||
max_concurrent_sessions=max_concurrent_sessions,
|
||||
unauthorized_dm_behavior=unauthorized_dm_behavior,
|
||||
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
|
||||
session_store_max_age_days=session_store_max_age_days,
|
||||
@@ -811,13 +761,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if "thread_sessions_per_user" in yaml_cfg:
|
||||
gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"]
|
||||
|
||||
gateway_section = yaml_cfg.get("gateway")
|
||||
if isinstance(gateway_section, dict) and "max_concurrent_sessions" in gateway_section:
|
||||
gw_data["max_concurrent_sessions"] = gateway_section["max_concurrent_sessions"]
|
||||
|
||||
if "max_concurrent_sessions" in yaml_cfg:
|
||||
gw_data["max_concurrent_sessions"] = yaml_cfg["max_concurrent_sessions"]
|
||||
|
||||
streaming_cfg = yaml_cfg.get("streaming")
|
||||
if not isinstance(streaming_cfg, dict):
|
||||
# Fall back to nested gateway.streaming written by
|
||||
@@ -1218,30 +1161,17 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(matrix_cfg, dict):
|
||||
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
|
||||
os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower()
|
||||
allowed_users = matrix_cfg.get("allowed_users")
|
||||
if allowed_users is not None and not os.getenv("MATRIX_ALLOWED_USERS"):
|
||||
if isinstance(allowed_users, list):
|
||||
allowed_users = ",".join(str(v) for v in allowed_users)
|
||||
os.environ["MATRIX_ALLOWED_USERS"] = str(allowed_users)
|
||||
allowed_rooms = matrix_cfg.get("allowed_rooms")
|
||||
if allowed_rooms is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
|
||||
if isinstance(allowed_rooms, list):
|
||||
allowed_rooms = ",".join(str(v) for v in allowed_rooms)
|
||||
os.environ["MATRIX_ALLOWED_ROOMS"] = str(allowed_rooms)
|
||||
frc = matrix_cfg.get("free_response_rooms")
|
||||
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
|
||||
ignore_patterns = matrix_cfg.get("ignore_user_patterns")
|
||||
if ignore_patterns is not None and not os.getenv("MATRIX_IGNORE_USER_PATTERNS"):
|
||||
if isinstance(ignore_patterns, list):
|
||||
ignore_patterns = ",".join(str(v) for v in ignore_patterns)
|
||||
os.environ["MATRIX_IGNORE_USER_PATTERNS"] = str(ignore_patterns)
|
||||
if "process_notices" in matrix_cfg and not os.getenv("MATRIX_PROCESS_NOTICES"):
|
||||
os.environ["MATRIX_PROCESS_NOTICES"] = str(matrix_cfg["process_notices"]).lower()
|
||||
if "session_scope" in matrix_cfg and not os.getenv("MATRIX_SESSION_SCOPE"):
|
||||
os.environ["MATRIX_SESSION_SCOPE"] = str(matrix_cfg["session_scope"]).lower()
|
||||
# allowed_rooms: if set, bot ONLY responds in these rooms (whitelist)
|
||||
ar = matrix_cfg.get("allowed_rooms")
|
||||
if ar is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
|
||||
if isinstance(ar, list):
|
||||
ar = ",".join(str(v) for v in ar)
|
||||
os.environ["MATRIX_ALLOWED_ROOMS"] = str(ar)
|
||||
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
|
||||
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
|
||||
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
|
||||
@@ -1510,14 +1440,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
matrix_password = os.getenv("MATRIX_PASSWORD", "")
|
||||
if matrix_password:
|
||||
matrix_config.extra["password"] = matrix_password
|
||||
matrix_e2ee_mode = os.getenv("MATRIX_E2EE_MODE", "").strip().lower()
|
||||
matrix_e2ee = (
|
||||
matrix_e2ee_mode in ("required", "require", "optional", "prefer", "preferred")
|
||||
or os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes")
|
||||
)
|
||||
matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"}
|
||||
matrix_config.extra["encryption"] = matrix_e2ee
|
||||
if matrix_e2ee_mode:
|
||||
matrix_config.extra["e2ee_mode"] = matrix_e2ee_mode
|
||||
matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "")
|
||||
if matrix_device_id:
|
||||
matrix_config.extra["device_id"] = matrix_device_id
|
||||
|
||||
@@ -3510,46 +3510,35 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _run():
|
||||
from gateway.session_context import clear_session_vars, set_session_vars
|
||||
|
||||
tokens = set_session_vars(
|
||||
platform="api_server",
|
||||
chat_id=session_id or "",
|
||||
session_key=gateway_session_key or session_id or "",
|
||||
session_id=session_id or "",
|
||||
agent = self._create_agent(
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
session_id=session_id,
|
||||
stream_delta_callback=stream_delta_callback,
|
||||
tool_progress_callback=tool_progress_callback,
|
||||
tool_start_callback=tool_start_callback,
|
||||
tool_complete_callback=tool_complete_callback,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
try:
|
||||
agent = self._create_agent(
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
session_id=session_id,
|
||||
stream_delta_callback=stream_delta_callback,
|
||||
tool_progress_callback=tool_progress_callback,
|
||||
tool_start_callback=tool_start_callback,
|
||||
tool_complete_callback=tool_complete_callback,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
if agent_ref is not None:
|
||||
agent_ref[0] = agent
|
||||
effective_task_id = session_id or str(uuid.uuid4())
|
||||
result = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
conversation_history=conversation_history,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
usage = {
|
||||
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
|
||||
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
|
||||
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
|
||||
}
|
||||
# Include the effective session ID in the result so callers
|
||||
# (e.g. X-Hermes-Session-Id header) can track compression-
|
||||
# triggered session rotations. (#16938)
|
||||
_eff_sid = getattr(agent, "session_id", session_id)
|
||||
if isinstance(_eff_sid, str) and _eff_sid:
|
||||
result["session_id"] = _eff_sid
|
||||
return result, usage
|
||||
finally:
|
||||
clear_session_vars(tokens)
|
||||
if agent_ref is not None:
|
||||
agent_ref[0] = agent
|
||||
effective_task_id = session_id or str(uuid.uuid4())
|
||||
result = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
conversation_history=conversation_history,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
usage = {
|
||||
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
|
||||
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
|
||||
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
|
||||
}
|
||||
# Include the effective session ID in the result so callers
|
||||
# (e.g. X-Hermes-Session-Id header) can track compression-
|
||||
# triggered session rotations. (#16938)
|
||||
_eff_sid = getattr(agent, "session_id", session_id)
|
||||
if isinstance(_eff_sid, str) and _eff_sid:
|
||||
result["session_id"] = _eff_sid
|
||||
return result, usage
|
||||
|
||||
return await loop.run_in_executor(None, _run)
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ _AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a', '.flac'})
|
||||
# delivered as a regular document.
|
||||
_TELEGRAM_AUDIO_ATTACHMENT_EXTS = frozenset({'.mp3', '.m4a'})
|
||||
_TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'})
|
||||
_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
def _platform_name(platform) -> str:
|
||||
@@ -1545,13 +1544,6 @@ 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),
|
||||
@@ -1803,26 +1795,11 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# Whether this platform renders triple-backtick fenced code blocks (i.e.
|
||||
# ``format_message`` translates/preserves markdown fences into a real code
|
||||
# block). Capability flag for markdown-aware presentation choices.
|
||||
# block). Drives presentation choices like rendering a ``terminal`` tool
|
||||
# call's command as a ```bash block instead of a flat preview line.
|
||||
# Default False (plain-text platforms); markdown-rendering adapters set True.
|
||||
# Tool-progress uses this to render a terminal command as a bare fenced code
|
||||
# block (no language tag — Slack mrkdwn would print the tag as a literal
|
||||
# first code line). Plain-text platforms fall back to the short truncated
|
||||
# preview (see gateway/run.py progress_callback).
|
||||
supports_code_blocks: bool = False
|
||||
|
||||
# The command prefix users can always TYPE on this platform to reach
|
||||
# Hermes commands. Default "/" (most platforms deliver "/approve" etc.
|
||||
# as plain message text). Platforms where typing a leading "/" is
|
||||
# intercepted or restricted by the client (Slack blocks native slash
|
||||
# commands inside threads; Matrix clients reserve "/" for client-local
|
||||
# commands) ship a "!" alias rewrite in their adapter and set this to
|
||||
# "!" so user-facing instruction text ("Reply `!approve` ...") tells
|
||||
# users the form that actually works everywhere. Capability flag —
|
||||
# shared prompt builders read it via getattr(adapter,
|
||||
# "typed_command_prefix", "/"); no per-platform branching at call sites.
|
||||
typed_command_prefix: str = "/"
|
||||
|
||||
def __init__(self, config: PlatformConfig, platform: Platform):
|
||||
self.config = config
|
||||
self.platform = platform
|
||||
@@ -4482,15 +4459,6 @@ class BasePlatformAdapter(ABC):
|
||||
except Exception:
|
||||
pass # Last resort — don't let error reporting crash the handler
|
||||
finally:
|
||||
# Stop typing before any deferred callback work. Post-delivery
|
||||
# callbacks may perform platform I/O; a stuck callback must not
|
||||
# leave the typing refresh task running indefinitely.
|
||||
await _stop_typing_task()
|
||||
try:
|
||||
if hasattr(self, "stop_typing"):
|
||||
await self.stop_typing(event.source.chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Fire any one-shot post-delivery callback registered for this
|
||||
# session (e.g. deferred background-review notifications).
|
||||
#
|
||||
@@ -4518,12 +4486,11 @@ class BasePlatformAdapter(ABC):
|
||||
try:
|
||||
_post_result = _post_cb()
|
||||
if inspect.isawaitable(_post_result):
|
||||
await asyncio.wait_for(
|
||||
_post_result,
|
||||
timeout=_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
await _post_result
|
||||
except Exception:
|
||||
pass
|
||||
# Stop typing indicator
|
||||
await _stop_typing_task()
|
||||
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
|
||||
# that may have been recreated by _keep_typing after the last stop_typing()
|
||||
try:
|
||||
@@ -4681,7 +4648,6 @@ class BasePlatformAdapter(ABC):
|
||||
guild_id: Optional[str] = None,
|
||||
parent_chat_id: Optional[str] = None,
|
||||
message_id: Optional[str] = None,
|
||||
role_authorized: bool = False,
|
||||
) -> SessionSource:
|
||||
"""Helper to build a SessionSource for this platform."""
|
||||
# Normalize empty topic to None
|
||||
@@ -4702,7 +4668,6 @@ class BasePlatformAdapter(ABC):
|
||||
guild_id=str(guild_id) if guild_id else None,
|
||||
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
|
||||
message_id=str(message_id) if message_id else None,
|
||||
role_authorized=role_authorized,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
|
||||
+291
-1414
File diff suppressed because it is too large
Load Diff
@@ -318,11 +318,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
|
||||
MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin
|
||||
supports_code_blocks = True # Slack mrkdwn renders fenced code blocks
|
||||
# Slack blocks typed native slash commands inside threads ("/approve is
|
||||
# not supported in threads. Sorry!"). The adapter rewrites a leading
|
||||
# "!" to "/" for known commands (see _handle_slack_message), so "!" is
|
||||
# the prefix that works everywhere — instruction text must show it.
|
||||
typed_command_prefix = "!"
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.SLACK)
|
||||
@@ -2697,26 +2692,19 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
cmd_preview = command[:2900] + "..." if len(command) > 2900 else command
|
||||
thread_ts = self._resolve_thread_ts(None, metadata)
|
||||
|
||||
# Slack hard-caps a section block's text at 3000 chars; an
|
||||
# oversized block fails the whole send with ``invalid_blocks``
|
||||
# and the gateway falls back to the plain-text prompt (no
|
||||
# buttons). execute_code approvals embed the entire script in
|
||||
# ``command``, so budget the preview against the fixed parts
|
||||
# instead of a flat truncation that overflows once the header +
|
||||
# reason are added.
|
||||
header = ":warning: *Command Approval Required*\n"
|
||||
reason = f"Reason: {description[:500]}"
|
||||
budget = 3000 - len(header) - len(reason) - len("``````\n") - len("...")
|
||||
cmd_preview = command[:budget] + "..." if len(command) > budget else command
|
||||
|
||||
blocks = [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": f"{header}```{cmd_preview}```\n{reason}",
|
||||
"text": (
|
||||
f":warning: *Command Approval Required*\n"
|
||||
f"```{cmd_preview}```\n"
|
||||
f"Reason: {description}"
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2784,13 +2772,8 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
body = message[:2900] + "..." if len(message) > 2900 else message
|
||||
thread_ts = self._resolve_thread_ts(None, metadata)
|
||||
# Same 3000-char section-block cap as send_exec_approval: budget
|
||||
# the body against the rendered title so the wrapper never pushes
|
||||
# the block over the limit (overflow → invalid_blocks → no buttons).
|
||||
_title = (title or "Confirm")[:150]
|
||||
budget = 3000 - len(f"*{_title}*\n\n") - len("...")
|
||||
body = message[:budget] + "..." if len(message) > budget else message
|
||||
# Encode session_key and confirm_id into the button value so the
|
||||
# callback handler can resolve without extra bookkeeping.
|
||||
value = f"{session_key}|{confirm_id}"
|
||||
@@ -2800,7 +2783,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": f"*{_title}*\n\n{body}",
|
||||
"text": f"*{title or 'Confirm'}*\n\n{body}",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+13
-176
@@ -181,8 +181,6 @@ def _strip_mdv2(text: str) -> str:
|
||||
"""
|
||||
# Remove escape backslashes before special characters
|
||||
cleaned = re.sub(r'\\([_*\[\]()~`>#\+\-=|{}.!\\])', r'\1', text)
|
||||
# Remove standard markdown bold (**text** → text) BEFORE MarkdownV2 bold
|
||||
cleaned = re.sub(r'\*\*([^*]+)\*\*', r'\1', cleaned)
|
||||
# Remove MarkdownV2 bold markers that format_message converted from **bold**
|
||||
cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned)
|
||||
# Remove MarkdownV2 italic markers that format_message converted from *italic*
|
||||
@@ -2210,17 +2208,11 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# "Message is not modified" is a no-op, not an error
|
||||
if "not modified" in str(fmt_err).lower():
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
# Fallback: strip MarkdownV2 escapes and retry as clean plain text
|
||||
logger.warning(
|
||||
"[%s] MarkdownV2 edit failed, falling back to plain text: %s",
|
||||
self.name,
|
||||
fmt_err,
|
||||
)
|
||||
_plain = _strip_mdv2(content) if content else content
|
||||
# Fallback: retry without markdown formatting
|
||||
await self._bot.edit_message_text(
|
||||
chat_id=int(chat_id),
|
||||
message_id=int(message_id),
|
||||
text=_plain,
|
||||
text=content,
|
||||
)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
except Exception as e:
|
||||
@@ -2348,15 +2340,10 @@ 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=_strip_mdv2(first_chunk),
|
||||
text=first_chunk,
|
||||
)
|
||||
else:
|
||||
await self._bot.edit_message_text(
|
||||
@@ -2384,7 +2371,6 @@ 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:]:
|
||||
@@ -2398,14 +2384,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
for use_markdown in (True, False) if finalize else (False,):
|
||||
try:
|
||||
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
|
||||
text = self.format_message(chunk) if use_markdown else chunk
|
||||
sent_msg = await self._bot.send_message(
|
||||
chat_id=int(chat_id),
|
||||
text=text,
|
||||
@@ -2431,7 +2410,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
sent_msg = await self._bot.send_message(
|
||||
chat_id=int(chat_id),
|
||||
text=_strip_mdv2(chunk) if finalize else chunk,
|
||||
text=chunk,
|
||||
**retry_thread_kwargs,
|
||||
**self._link_preview_kwargs(),
|
||||
**self._notification_kwargs(metadata),
|
||||
@@ -2455,37 +2434,17 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
break
|
||||
if sent_msg is None:
|
||||
# Continuation failed — the user has chunk 1 + however many
|
||||
# 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.
|
||||
# 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.
|
||||
logger.warning(
|
||||
"[%s] Overflow split: stopped at %d/%d chunks delivered",
|
||||
self.name, 1 + len(continuation_ids), len(chunks),
|
||||
)
|
||||
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),
|
||||
)
|
||||
break
|
||||
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
|
||||
@@ -3063,7 +3022,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
async def _handle_model_picker_callback(
|
||||
self, query, data: str, chat_id: str
|
||||
) -> None:
|
||||
"""Handle model picker inline keyboard callbacks (mp:/mm:/mc:/mb:/mx:/mg:)."""
|
||||
"""Handle model picker inline keyboard callbacks (mp:/mm:/mb:/mx:/mg:)."""
|
||||
state = self._model_picker_state.get(chat_id)
|
||||
if not state:
|
||||
await query.answer(text="Picker expired — use /model again.")
|
||||
@@ -3148,55 +3107,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
await query.answer()
|
||||
|
||||
elif data.startswith("mc:"):
|
||||
# --- Expensive model confirmed: perform the switch ---
|
||||
try:
|
||||
idx = int(data[3:])
|
||||
except ValueError:
|
||||
await query.answer(text="Invalid selection.")
|
||||
return
|
||||
|
||||
model_list = state.get("model_list", [])
|
||||
if idx < 0 or idx >= len(model_list):
|
||||
await query.answer(text="Invalid model index.")
|
||||
return
|
||||
|
||||
model_id = model_list[idx]
|
||||
provider_slug = state.get("selected_provider", "")
|
||||
callback = state.get("on_model_selected")
|
||||
|
||||
if not callback:
|
||||
await query.answer(text="Picker expired.")
|
||||
return
|
||||
|
||||
switch_failed = False
|
||||
try:
|
||||
result_text = await callback(chat_id, model_id, provider_slug)
|
||||
except Exception as exc:
|
||||
logger.error("Model picker switch failed: %s", exc)
|
||||
result_text = f"Error switching model: {exc}"
|
||||
switch_failed = True
|
||||
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=self.format_message(result_text),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=result_text,
|
||||
parse_mode=None,
|
||||
reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await query.answer(
|
||||
text="Switch failed." if switch_failed else "Model switched!"
|
||||
)
|
||||
self._model_picker_state.pop(chat_id, None)
|
||||
|
||||
elif data.startswith("mm:"):
|
||||
# --- Model selected: perform the switch ---
|
||||
try:
|
||||
@@ -3218,43 +3128,11 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
await query.answer(text="Picker expired.")
|
||||
return
|
||||
|
||||
try:
|
||||
from hermes_cli.model_cost_guard import expensive_model_warning
|
||||
|
||||
# Pricing lookup can hit models.dev / a /models endpoint on a
|
||||
# cache miss — keep it off the event loop.
|
||||
warning = await asyncio.to_thread(
|
||||
expensive_model_warning,
|
||||
model_id,
|
||||
provider=provider_slug,
|
||||
)
|
||||
except Exception:
|
||||
warning = None
|
||||
if warning is not None:
|
||||
keyboard = InlineKeyboardMarkup([
|
||||
[InlineKeyboardButton("Switch anyway", callback_data=f"mc:{idx}")],
|
||||
[
|
||||
InlineKeyboardButton("◀ Back", callback_data="mb"),
|
||||
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
|
||||
],
|
||||
])
|
||||
await query.edit_message_text(
|
||||
text=self.format_message(
|
||||
f"⚠ *Expensive Model Warning*\n\n{warning.message}"
|
||||
),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await query.answer(text="Confirm expensive model")
|
||||
return
|
||||
|
||||
switch_failed = False
|
||||
try:
|
||||
result_text = await callback(chat_id, model_id, provider_slug)
|
||||
except Exception as exc:
|
||||
logger.error("Model picker switch failed: %s", exc)
|
||||
result_text = f"Error switching model: {exc}"
|
||||
switch_failed = True
|
||||
|
||||
# Edit message to show confirmation, remove buttons
|
||||
try:
|
||||
@@ -3273,9 +3151,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await query.answer(
|
||||
text="Switch failed." if switch_failed else "Model switched!"
|
||||
)
|
||||
await query.answer(text="Model switched!")
|
||||
|
||||
# Clean up state
|
||||
self._model_picker_state.pop(chat_id, None)
|
||||
@@ -3376,7 +3252,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
query_user_name = getattr(query.from_user, "first_name", None)
|
||||
|
||||
# --- Model picker callbacks ---
|
||||
if data.startswith(("mp:", "mpg:", "mm:", "mc:", "mb", "mx", "mg:")):
|
||||
if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")):
|
||||
chat_id = str(query.message.chat_id) if query.message else None
|
||||
if chat_id:
|
||||
await self._handle_model_picker_callback(query, data, chat_id)
|
||||
@@ -3837,33 +3713,6 @@ 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,
|
||||
@@ -5629,12 +5478,6 @@ 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")
|
||||
@@ -5645,12 +5488,6 @@ 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")
|
||||
|
||||
@@ -191,22 +191,6 @@ 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.
|
||||
@@ -603,21 +587,9 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
logger.warning("[%s] Could not acquire session lock (non-fatal): %s", self.name, e)
|
||||
|
||||
try:
|
||||
# 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.
|
||||
# Auto-install npm dependencies if node_modules doesn't exist
|
||||
bridge_dir = bridge_path.parent
|
||||
_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:
|
||||
if not (bridge_dir / "node_modules").exists():
|
||||
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
|
||||
@@ -638,11 +610,6 @@ class WhatsAppAdapter(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
|
||||
@@ -662,28 +629,12 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
data = await resp.json()
|
||||
bridge_status = data.get("status", "unknown")
|
||||
if bridge_status == "connected":
|
||||
# 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"
|
||||
)
|
||||
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
|
||||
else:
|
||||
print(f"[{self.name}] Bridge found but not connected (status: {bridge_status}), restarting")
|
||||
except Exception:
|
||||
@@ -708,18 +659,6 @@ class WhatsAppAdapter(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(
|
||||
[
|
||||
|
||||
+175
-271
@@ -157,12 +157,6 @@ _YB_RES_REF_RE = re.compile(
|
||||
r"\[(image|voice|video|file(?::[^|\]]*)?)\|ybres:([A-Za-z0-9_\-]+)\]"
|
||||
)
|
||||
|
||||
# Patched local-media anchors once an inbound resource has been downloaded to the local cache.
|
||||
# [image: /opt/data/image_cache/img_xxx.bmp]
|
||||
# [file: report.pdf → /opt/data/.../report.pdf]
|
||||
# (and any future kind, e.g. [video: /opt/.../clip.mp4])
|
||||
_YB_LOCAL_MEDIA_RE = re.compile(r"\[(\w+):[^\]]*?(/[^\]]+?)\s*\]")
|
||||
|
||||
# Media kinds that can be resolved and injected into the model context
|
||||
_RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file"})
|
||||
|
||||
@@ -946,11 +940,7 @@ class InboundContext:
|
||||
reply_to_text: Optional[str] = None
|
||||
quote_media_refs: list = dc_field(default_factory=list) # List of (rid, kind, filename)
|
||||
|
||||
# Populated by MediaResolveMiddleware. Combined list of resolved local
|
||||
# paths from up to three sources (deduped, in this order):
|
||||
# 1) media carried by the current message (always),
|
||||
# 2) media from the quoted message (when reply_to_message_id is set),
|
||||
# 3) recent group-observed media (only when chat_type == "group" and no quote is present).
|
||||
# Populated by MediaResolveMiddleware
|
||||
media_urls: list = dc_field(default_factory=list)
|
||||
media_types: list = dc_field(default_factory=list)
|
||||
|
||||
@@ -1695,10 +1685,10 @@ class ExtractContentMiddleware(InboundMiddleware):
|
||||
"""Extract plain text content from MsgBody.
|
||||
|
||||
- TIMTextElem -> text field
|
||||
- TIMImageElem -> "[image]" / "[image|ybres:RID]"
|
||||
- TIMFileElem -> "[file: {filename}]" / "[file:{name}|ybres:RID]"
|
||||
- TIMSoundElem -> "[voice]" / "[voice|ybres:RID]"
|
||||
- TIMVideoFileElem -> "[video]" / "[video|ybres:RID]"
|
||||
- TIMImageElem -> "[image]"
|
||||
- TIMFileElem -> "[file: {filename}]"
|
||||
- TIMSoundElem -> "[voice]"
|
||||
- TIMVideoFileElem -> "[video]"
|
||||
- TIMFaceElem -> "[emoji: {name}]" or "[emoji]"
|
||||
- TIMCustomElem -> try to extract data field, otherwise "[custom message]"
|
||||
- Multiple elems joined with spaces
|
||||
@@ -2197,72 +2187,51 @@ class QuoteContextMiddleware(InboundMiddleware):
|
||||
|
||||
name = "quote-context"
|
||||
|
||||
def _extract_quote_context(self, cloud_custom_data: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Extract quote text context, mapping to MessageEvent.reply_to_*.
|
||||
@staticmethod
|
||||
def _extract_quote_context(cloud_custom_data: str) -> Tuple[Optional[str], Optional[str], list]:
|
||||
"""Extract quote context, mapping to MessageEvent.reply_to_*.
|
||||
|
||||
Returns:
|
||||
(reply_to_message_id, reply_to_text, quote_media_refs)
|
||||
where quote_media_refs is a list of (rid, kind, filename) tuples
|
||||
"""
|
||||
if not cloud_custom_data:
|
||||
return None, None
|
||||
return None, None, []
|
||||
try:
|
||||
parsed = json.loads(cloud_custom_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None, None
|
||||
return None, None, []
|
||||
|
||||
quote = parsed.get("quote") if isinstance(parsed, dict) else None
|
||||
if not isinstance(quote, dict):
|
||||
return None, None
|
||||
return None, None, []
|
||||
|
||||
# type=2 corresponds to image reference; desc may be empty, provide a placeholder.
|
||||
quote_type = int(quote.get("type") or 0)
|
||||
desc = str(quote.get("desc") or "").strip()
|
||||
if quote_type == 2 and not desc:
|
||||
desc = "[image]"
|
||||
if not desc:
|
||||
return None, None, []
|
||||
|
||||
quote_id = str(quote.get("id") or "").strip() or None
|
||||
desc = str(quote.get("desc") or "").strip()
|
||||
sender = str(quote.get("sender_nickname") or quote.get("sender_id") or "").strip()
|
||||
quote_text = (f"{sender}: {desc}" if sender else desc) if desc else None
|
||||
quote_text = f"{sender}: {desc}" if sender else desc
|
||||
|
||||
return quote_id, quote_text
|
||||
# Extract media references from desc using _YB_RES_REF_RE regex
|
||||
media_refs: list = []
|
||||
for m in _YB_RES_REF_RE.finditer(desc):
|
||||
head = m.group(1) # "image" | "file:<name>" | "voice" | "video"
|
||||
rid = m.group(2)
|
||||
kind, _, filename = head.partition(":")
|
||||
kind = kind.strip()
|
||||
media_refs.append((rid, kind, filename.strip()))
|
||||
|
||||
async def _extract_media_refs_from_transcript(
|
||||
self, ctx: InboundContext
|
||||
) -> List[Tuple[str, str, str]]:
|
||||
"""Look up the quoted message in the transcript history and return any
|
||||
``[kind|ybres:RID]`` anchors found in its content as
|
||||
``(rid, kind, filename)`` tuples.
|
||||
|
||||
Returns ``[]`` when ``ctx.reply_to_message_id`` is unset, when the
|
||||
transcript store / source is unavailable, or when the quoted message
|
||||
carries no resolvable media anchors.
|
||||
"""
|
||||
if ctx.reply_to_message_id is None:
|
||||
return []
|
||||
adapter = ctx.adapter
|
||||
media_refs: List[Tuple[str, str, str]] = []
|
||||
try:
|
||||
store = getattr(adapter, "_session_store", None)
|
||||
if not store or ctx.source is None:
|
||||
return []
|
||||
session_entry = store.get_or_create_session(ctx.source)
|
||||
history = store.load_transcript(session_entry.session_id)
|
||||
for msg in reversed(history or []):
|
||||
mid = msg.get("message_id", "")
|
||||
if not mid or mid != ctx.reply_to_message_id:
|
||||
continue
|
||||
_content = msg.get("content", "")
|
||||
if isinstance(_content, str) and "|ybres:" in _content:
|
||||
for m in _YB_RES_REF_RE.finditer(_content):
|
||||
head = m.group(1)
|
||||
rid = m.group(2)
|
||||
kind, _, filename = head.partition(":")
|
||||
kind = kind.strip()
|
||||
if kind in _RESOLVABLE_MEDIA_KINDS:
|
||||
media_refs.append((rid, kind, filename.strip()))
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] quote transcript lookup failed: %s",
|
||||
getattr(adapter, "name", "yuanbao"), exc,
|
||||
)
|
||||
return media_refs
|
||||
return quote_id, quote_text, media_refs
|
||||
|
||||
async def handle(self, ctx: InboundContext, next_fn) -> None:
|
||||
ctx.reply_to_message_id, ctx.reply_to_text = self._extract_quote_context(ctx.cloud_custom_data)
|
||||
ctx.quote_media_refs = await self._extract_media_refs_from_transcript(ctx)
|
||||
ctx.reply_to_message_id, ctx.reply_to_text, ctx.quote_media_refs = self._extract_quote_context(ctx.cloud_custom_data)
|
||||
|
||||
await next_fn()
|
||||
|
||||
|
||||
@@ -2467,6 +2436,11 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
cls._put_cached_resource(resource_id, local_path, mime)
|
||||
return local_path, mime
|
||||
|
||||
@classmethod
|
||||
async def _resolve_by_resource_id(cls, adapter, resource_id: str) -> str:
|
||||
"""Exchange a Yuanbao ``resourceId`` for a short-lived direct download URL. Raises on failure."""
|
||||
return await cls._fetch_resource_url(adapter, resource_id)
|
||||
|
||||
@classmethod
|
||||
async def _resolve_media_urls(
|
||||
cls, adapter, media_refs: List[Dict[str, str]]
|
||||
@@ -2482,7 +2456,6 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
for ref in media_refs:
|
||||
kind = str(ref.get("kind") or "").strip().lower()
|
||||
url = str(ref.get("url") or "").strip()
|
||||
filename = str(ref.get("name") or "").strip()
|
||||
if kind not in _RESOLVABLE_MEDIA_KINDS or not url:
|
||||
continue
|
||||
|
||||
@@ -2502,7 +2475,7 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
adapter,
|
||||
fetch_url=fetch_url,
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
file_name=str(ref.get("name") or "").strip() or None,
|
||||
log_tag=f"placeholder_url={url[:80]}",
|
||||
resource_id=rid,
|
||||
)
|
||||
@@ -2514,44 +2487,6 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
|
||||
return media_urls, media_types
|
||||
|
||||
@classmethod
|
||||
async def _resolve_ybres_refs(
|
||||
cls,
|
||||
adapter,
|
||||
refs: List[Tuple[str, str, str]],
|
||||
*,
|
||||
log_prefix: str,
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Resolve a list of ``(rid, kind, filename)`` ybres tuples to local paths.
|
||||
"""
|
||||
media_paths: List[str] = []
|
||||
mimes: List[str] = []
|
||||
for rid, kind, filename in refs:
|
||||
if kind not in _RESOLVABLE_MEDIA_KINDS:
|
||||
continue
|
||||
try:
|
||||
fresh_url = await cls._fetch_resource_url(adapter, rid)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] %s resolve failed: rid=%s kind=%s err=%s",
|
||||
adapter.name, log_prefix, rid, kind, exc,
|
||||
)
|
||||
continue
|
||||
cached = await cls._download_and_cache(
|
||||
adapter,
|
||||
fetch_url=fresh_url,
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
log_tag=f"{log_prefix} rid={rid}",
|
||||
resource_id=rid,
|
||||
)
|
||||
if cached is None:
|
||||
continue
|
||||
path, mime = cached
|
||||
media_paths.append(path)
|
||||
mimes.append(mime)
|
||||
return media_paths, mimes
|
||||
|
||||
@classmethod
|
||||
async def _collect_observed_media(
|
||||
cls, adapter, source,
|
||||
@@ -2598,175 +2533,39 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
if not order:
|
||||
return [], []
|
||||
|
||||
return await cls._resolve_ybres_refs(
|
||||
adapter, order, log_prefix="observed-media",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _resolve_quote_media(
|
||||
cls, adapter, quote_media_refs: List[Tuple[str, str, str]],
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Resolve media anchors carried by the quoted message.
|
||||
|
||||
``quote_media_refs`` is a list of ``(rid, kind, filename)`` tuples
|
||||
produced by :class:`QuoteContextMiddleware` from the transcript.
|
||||
"""
|
||||
return await cls._resolve_ybres_refs(
|
||||
adapter, quote_media_refs, log_prefix="quote",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _collect_quote_local_media(ctx: InboundContext) -> Tuple[List[str], List[str]]:
|
||||
"""Private-chat fallback for recovering already-local quoted media.
|
||||
|
||||
Only already-local media is handled here: by the time a turn is cached,
|
||||
``PatchAnchorsMiddleware`` has rewritten resolved ``|ybres:`` anchors to
|
||||
``[image: /path]`` / ``[file: name → /path]``. Unresolved anchors are an
|
||||
original-turn resolution failure and belong to that turn's handling, not
|
||||
this quote fallback — so no re-download happens here.
|
||||
|
||||
Returns ``(local_paths, mimes)`` for media already downloaded to the
|
||||
local cache on its original turn, ready to inject as-is.
|
||||
"""
|
||||
paths: List[str] = []
|
||||
media_paths: List[str] = []
|
||||
mimes: List[str] = []
|
||||
rid_key = ctx.reply_to_message_id
|
||||
if not rid_key:
|
||||
return paths, mimes
|
||||
cache = getattr(ctx.adapter, "_msg_content_cache", None)
|
||||
if not cache:
|
||||
return paths, mimes
|
||||
text = cache.get(rid_key)
|
||||
if not isinstance(text, str) or not text:
|
||||
return paths, mimes
|
||||
|
||||
# Already-local media paths written by PatchAnchorsMiddleware. The
|
||||
# generic anchor regex covers every kind _patch emits (image/file today,
|
||||
# video/audio if they later become resolvable) without per-kind upkeep.
|
||||
seen: set = set()
|
||||
for m in _YB_LOCAL_MEDIA_RE.finditer(text):
|
||||
kind = (m.group(1) or "").strip().lower()
|
||||
path = (m.group(2) or "").strip()
|
||||
if not path or path in seen:
|
||||
continue
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
seen.add(path)
|
||||
mime = guess_mime_type(os.path.basename(path)) or (
|
||||
"image/jpeg" if kind == "image" else "application/octet-stream"
|
||||
)
|
||||
paths.append(path)
|
||||
mimes.append(mime)
|
||||
|
||||
return paths, mimes
|
||||
|
||||
async def handle(self, ctx: InboundContext, next_fn) -> None:
|
||||
# NOTE: Reaching this middleware in a group chat implies the message has
|
||||
# @-mentioned the bot (or is an owner command). GroupAtGuardMiddleware
|
||||
# short-circuits non-@bot group messages earlier in the pipeline, so we
|
||||
# don't need to re-check @bot status here before downloading media.
|
||||
adapter = ctx.adapter
|
||||
|
||||
urls: List[str] = []
|
||||
types: List[str] = []
|
||||
seen: set = set()
|
||||
|
||||
def _add_unique_pairs(pair_lists: Tuple[List[str], List[str]]) -> None:
|
||||
u_list, m_list = pair_lists
|
||||
for u, m in zip(u_list, m_list):
|
||||
if not u or u in seen:
|
||||
continue
|
||||
seen.add(u)
|
||||
urls.append(u)
|
||||
types.append(m)
|
||||
|
||||
# 1) Media carried by the current message itself.
|
||||
own_pairs = await self._resolve_media_urls(adapter, ctx.media_refs)
|
||||
own_count = sum(1 for u in own_pairs[0] if u)
|
||||
_add_unique_pairs(own_pairs)
|
||||
|
||||
# 2) Second source — quoted media takes priority; otherwise fall back
|
||||
# to observed-media backfill in groups only (DMs already had their
|
||||
# media resolved on the turn it was sent).
|
||||
if ctx.reply_to_message_id is not None:
|
||||
if ctx.quote_media_refs:
|
||||
_add_unique_pairs(await self._resolve_quote_media(adapter, ctx.quote_media_refs))
|
||||
else:
|
||||
# DM quote fallback: no transcript message_id match (DM user rows
|
||||
# carry no platform message_id), so recover already-local media
|
||||
# from the adapter msg cache. Patched on its original turn — no
|
||||
# re-download needed, inject as-is.
|
||||
_add_unique_pairs(self._collect_quote_local_media(ctx))
|
||||
elif ctx.chat_type == "group":
|
||||
# Group chats: only @-bot turns reach this middleware
|
||||
# (see GroupAtGuardMiddleware note at top of handle()),
|
||||
# so unconditional observed-media hydration is safe here.
|
||||
for rid, kind, filename in order:
|
||||
try:
|
||||
_add_unique_pairs(await self._collect_observed_media(adapter, ctx.source))
|
||||
fresh_url = await cls._resolve_by_resource_id(adapter, rid)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] observed-image hydration raised, continuing anyway: %s",
|
||||
adapter.name, exc,
|
||||
"[%s] observed-media resolve failed: rid=%s kind=%s err=%s",
|
||||
adapter.name, rid, kind, exc,
|
||||
)
|
||||
|
||||
ctx.media_urls = urls
|
||||
ctx.media_types = types
|
||||
|
||||
# Re-check placeholder after media resolution.
|
||||
# Use ``own_count`` (not ``len(urls)``) to preserve the original
|
||||
# semantics: a placeholder text accompanied only by quote/observed
|
||||
# media (i.e. no fresh attachment of its own) is still skippable.
|
||||
if PlaceholderFilterMiddleware.is_skippable_placeholder(ctx.raw_text, own_count):
|
||||
logger.debug("[%s] Skip placeholder after media download: %r", adapter.name, ctx.raw_text)
|
||||
return # Stop pipeline
|
||||
await next_fn()
|
||||
|
||||
|
||||
class PatchAnchorsMiddleware(InboundMiddleware):
|
||||
"""Replace ``[kind|ybres:RID]`` anchors in ``ctx.raw_text`` with local paths.
|
||||
|
||||
Runs after :class:`MediaResolveMiddleware` so that ``ctx.media_urls`` /
|
||||
``ctx.media_types`` are already populated with downloaded resources
|
||||
(own media + quote media or group-observed media). The transcript
|
||||
written downstream then records usable local paths for the model
|
||||
instead of opaque ``ybres:`` references.
|
||||
|
||||
Only resolved media (paths starting with ``/``) are substituted; any
|
||||
anchor without a corresponding local resource is left untouched.
|
||||
"""
|
||||
|
||||
name = "patch-anchors"
|
||||
|
||||
@staticmethod
|
||||
def _patch(text: str, urls: List[str], types: List[str]) -> str:
|
||||
if not text or not urls:
|
||||
return text
|
||||
patched = text
|
||||
for u, m in zip(urls, types):
|
||||
if not u.startswith("/"):
|
||||
continue
|
||||
anchor_match = _YB_RES_REF_RE.search(patched)
|
||||
if not anchor_match:
|
||||
break
|
||||
head = anchor_match.group(1)
|
||||
kind, _, filename = head.partition(":")
|
||||
kind = kind.strip()
|
||||
if kind == "image" and m.startswith("image/"):
|
||||
replacement = f"[image: {u}]"
|
||||
elif kind == "file":
|
||||
label = filename.strip() or os.path.basename(u)
|
||||
replacement = f"[file: {label} → {u}]"
|
||||
else:
|
||||
continue
|
||||
patched = (
|
||||
patched[: anchor_match.start()]
|
||||
+ replacement
|
||||
+ patched[anchor_match.end():]
|
||||
cached = await cls._download_and_cache(
|
||||
adapter,
|
||||
fetch_url=fresh_url,
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
log_tag=f"rid={rid}",
|
||||
resource_id=rid,
|
||||
)
|
||||
return patched
|
||||
if cached is None:
|
||||
continue
|
||||
path, mime = cached
|
||||
media_paths.append(path)
|
||||
mimes.append(mime)
|
||||
return media_paths, mimes
|
||||
|
||||
async def handle(self, ctx: InboundContext, next_fn) -> None:
|
||||
ctx.raw_text = self._patch(ctx.raw_text, ctx.media_urls, ctx.media_types)
|
||||
adapter = ctx.adapter
|
||||
ctx.media_urls, ctx.media_types = await self._resolve_media_urls(adapter, ctx.media_refs)
|
||||
# Re-check placeholder after media resolution
|
||||
if PlaceholderFilterMiddleware.is_skippable_placeholder(ctx.raw_text, len(ctx.media_urls)):
|
||||
logger.debug("[%s] Skip placeholder after media download: %r", adapter.name, ctx.raw_text)
|
||||
return # Stop pipeline
|
||||
await next_fn()
|
||||
|
||||
|
||||
@@ -2785,18 +2584,124 @@ class DispatchMiddleware(InboundMiddleware):
|
||||
)
|
||||
|
||||
async def _dispatch_inbound_event() -> None:
|
||||
media_urls = list(ctx.media_urls)
|
||||
media_types = list(ctx.media_types)
|
||||
|
||||
# If user quoted a message (reply_to_message_id is set), resolve only
|
||||
# quote_media_refs to avoid injecting unrelated history media.
|
||||
# Otherwise, backfill observed media from recent transcript history.
|
||||
if ctx.reply_to_message_id is not None:
|
||||
# Fallback: if desc didn't contain ybres refs, look up transcript
|
||||
if not ctx.quote_media_refs:
|
||||
try:
|
||||
store = getattr(adapter, "_session_store", None)
|
||||
if store:
|
||||
session_entry = store.get_or_create_session(ctx.source)
|
||||
history = store.load_transcript(session_entry.session_id)
|
||||
for msg in reversed(history or []):
|
||||
mid = msg.get("message_id", "")
|
||||
if mid and mid == ctx.reply_to_message_id:
|
||||
_content = msg.get("content", "")
|
||||
if isinstance(_content, str) and "|ybres:" in _content:
|
||||
for m in _YB_RES_REF_RE.finditer(_content):
|
||||
head = m.group(1)
|
||||
rid = m.group(2)
|
||||
kind, _, filename = head.partition(":")
|
||||
kind = kind.strip()
|
||||
if kind in _RESOLVABLE_MEDIA_KINDS:
|
||||
ctx.quote_media_refs.append((rid, kind, filename.strip()))
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] quote transcript lookup failed: %s",
|
||||
adapter.name, exc,
|
||||
)
|
||||
# User quoted a message — resolve only media from the quote
|
||||
for rid, kind, filename in ctx.quote_media_refs:
|
||||
if kind not in _RESOLVABLE_MEDIA_KINDS:
|
||||
continue
|
||||
try:
|
||||
fresh_url = await MediaResolveMiddleware._resolve_by_resource_id(adapter, rid)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] quote media resolve failed: rid=%s kind=%s err=%s",
|
||||
adapter.name, rid, kind, exc,
|
||||
)
|
||||
continue
|
||||
cached = await MediaResolveMiddleware._download_and_cache(
|
||||
adapter,
|
||||
fetch_url=fresh_url,
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
log_tag=f"quote rid={rid}",
|
||||
resource_id=rid,
|
||||
)
|
||||
if cached is None:
|
||||
continue
|
||||
path, mime = cached
|
||||
# Avoid duplicates
|
||||
if path not in media_urls:
|
||||
media_urls.append(path)
|
||||
media_types.append(mime)
|
||||
else:
|
||||
# No quote — backfill observed media from recent transcript history
|
||||
extra_img_urls: List[str] = []
|
||||
extra_img_mimes: List[str] = []
|
||||
try:
|
||||
extra_img_urls, extra_img_mimes = await MediaResolveMiddleware._collect_observed_media(
|
||||
adapter, ctx.source,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] observed-image hydration raised, continuing anyway: %s",
|
||||
adapter.name, exc,
|
||||
)
|
||||
if extra_img_urls:
|
||||
current = set(media_urls)
|
||||
for u, m in zip(extra_img_urls, extra_img_mimes):
|
||||
if u in current:
|
||||
continue
|
||||
media_urls.append(u)
|
||||
media_types.append(m)
|
||||
current.add(u)
|
||||
|
||||
# Replace [kind|ybres:xxx] anchors with local cache paths so
|
||||
# the transcript records usable paths for the model.
|
||||
_patched_event_text = ctx.raw_text
|
||||
for u, m in zip(media_urls, media_types):
|
||||
if not u.startswith("/"):
|
||||
continue
|
||||
anchor_match = _YB_RES_REF_RE.search(_patched_event_text)
|
||||
if not anchor_match:
|
||||
continue
|
||||
head = anchor_match.group(1)
|
||||
kind, _, filename = head.partition(":")
|
||||
kind = kind.strip()
|
||||
if kind == "image" and m.startswith("image/"):
|
||||
replacement = f"[image: {u}]"
|
||||
elif kind == "file":
|
||||
label = filename.strip() or os.path.basename(u)
|
||||
replacement = f"[file: {label} → {u}]"
|
||||
else:
|
||||
continue
|
||||
_patched_event_text = (
|
||||
_patched_event_text[:anchor_match.start()]
|
||||
+ replacement
|
||||
+ _patched_event_text[anchor_match.end():]
|
||||
)
|
||||
|
||||
event = MessageEvent(
|
||||
text=ctx.raw_text,
|
||||
text=_patched_event_text,
|
||||
message_type=(
|
||||
MessageType.DOCUMENT
|
||||
if any(mt.startswith(("application/", "text/")) for mt in ctx.media_types)
|
||||
if any(mt.startswith(("application/", "text/")) for mt in media_types)
|
||||
else ctx.msg_type
|
||||
),
|
||||
source=ctx.source,
|
||||
message_id=ctx.msg_id or None,
|
||||
raw_message=ctx.push,
|
||||
media_urls=list(ctx.media_urls),
|
||||
media_types=list(ctx.media_types),
|
||||
media_urls=media_urls,
|
||||
media_types=media_types,
|
||||
reply_to_message_id=ctx.reply_to_message_id,
|
||||
reply_to_text=ctx.reply_to_text,
|
||||
channel_prompt=ctx.channel_prompt,
|
||||
@@ -2890,7 +2795,6 @@ class InboundPipelineBuilder:
|
||||
ClassifyMessageTypeMiddleware,
|
||||
QuoteContextMiddleware,
|
||||
MediaResolveMiddleware,
|
||||
PatchAnchorsMiddleware,
|
||||
DispatchMiddleware,
|
||||
]
|
||||
|
||||
|
||||
+3744
-790
File diff suppressed because it is too large
Load Diff
@@ -91,7 +91,6 @@ class SessionSource:
|
||||
guild_id: Optional[str] = None # Discord guild / Slack workspace / Matrix server scope
|
||||
parent_chat_id: Optional[str] = None # Parent channel when chat_id refers to a thread
|
||||
message_id: Optional[str] = None # ID of the triggering message (for pin/reply/react)
|
||||
role_authorized: bool = False # True when adapter granted access via role (not user ID)
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
@@ -294,22 +293,6 @@ def build_session_context_prompt(
|
||||
if context.source.chat_topic:
|
||||
lines.append(f"**Channel Topic:** {context.source.chat_topic}")
|
||||
|
||||
if context.source.platform == Platform.MATRIX:
|
||||
src = context.source
|
||||
room_name = src.chat_name or src.chat_id
|
||||
room_id = _hash_chat_id(src.chat_id) if redact_pii else src.chat_id
|
||||
lines.append("")
|
||||
lines.append(f"**Matrix Room:** {room_name}")
|
||||
lines.append(f"**Matrix Room ID:** {room_id}")
|
||||
if src.thread_id:
|
||||
thread_id = _hash_chat_id(src.thread_id) if redact_pii else src.thread_id
|
||||
lines.append(f"**Matrix Thread:** {thread_id}")
|
||||
lines.append(
|
||||
"**Matrix room boundary:** Treat this turn as scoped to the current "
|
||||
"Matrix room/thread only. Do not assume unresolved references are "
|
||||
"about other Matrix rooms or projects unless the user explicitly says so."
|
||||
)
|
||||
|
||||
# User identity.
|
||||
# In shared multi-user sessions (shared threads OR shared non-thread groups
|
||||
# when group_sessions_per_user=False), multiple users contribute to the same
|
||||
@@ -1280,17 +1263,6 @@ class SessionStore:
|
||||
entries.sort(key=lambda e: e.updated_at, reverse=True)
|
||||
|
||||
return entries
|
||||
|
||||
def lookup_by_session_id(self, session_id: str) -> Optional[SessionEntry]:
|
||||
"""Return the active session entry for a persisted session ID, if any."""
|
||||
if not session_id:
|
||||
return None
|
||||
with self._lock:
|
||||
self._ensure_loaded_locked()
|
||||
for entry in self._entries.values():
|
||||
if entry.session_id == session_id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db: bool = False) -> None:
|
||||
"""Append a message to a session's transcript (SQLite).
|
||||
|
||||
@@ -106,7 +106,6 @@ def set_session_vars(
|
||||
user_id: str = "",
|
||||
user_name: str = "",
|
||||
session_key: str = "",
|
||||
session_id: str = "",
|
||||
message_id: str = "",
|
||||
cwd: str = "",
|
||||
) -> list:
|
||||
@@ -128,7 +127,6 @@ def set_session_vars(
|
||||
_SESSION_USER_ID.set(user_id),
|
||||
_SESSION_USER_NAME.set(user_name),
|
||||
_SESSION_KEY.set(session_key),
|
||||
_SESSION_ID.set(session_id),
|
||||
_SESSION_MESSAGE_ID.set(message_id),
|
||||
]
|
||||
try:
|
||||
@@ -159,7 +157,6 @@ def clear_session_vars(tokens: list) -> None:
|
||||
_SESSION_USER_ID,
|
||||
_SESSION_USER_NAME,
|
||||
_SESSION_KEY,
|
||||
_SESSION_ID,
|
||||
_SESSION_MESSAGE_ID,
|
||||
):
|
||||
var.set("")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -147,15 +147,8 @@ class GatewayStreamConsumer:
|
||||
self._edit_supported = True # Disabled when progressive edits are no longer usable
|
||||
self._last_edit_time = 0.0
|
||||
self._last_sent_text = "" # Track last-sent text to skip redundant edits
|
||||
# True when the most recent _send_or_edit split-and-delivered across
|
||||
# continuation messages (the adapter adopted a new message id).
|
||||
self._last_edit_overflowed = False
|
||||
self._fallback_final_send = False
|
||||
self._fallback_prefix = ""
|
||||
# True when fallback is sending only the missing tail after a partial
|
||||
# Telegram overflow delivery. In that case the already-visible prefix
|
||||
# is intentional content, not a stale preview to delete.
|
||||
self._fallback_preserve_partial_messages = False
|
||||
self._flood_strikes = 0 # Consecutive flood-control edit failures
|
||||
self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff
|
||||
self._final_response_sent = False
|
||||
@@ -268,7 +261,6 @@ class GatewayStreamConsumer:
|
||||
self._last_sent_text = ""
|
||||
self._fallback_final_send = False
|
||||
self._fallback_prefix = ""
|
||||
self._fallback_preserve_partial_messages = False
|
||||
# #29346: a tool/segment boundary means what we delivered was an interim
|
||||
# preamble, not the final answer — clear the flags so a premature setter
|
||||
# can't fool the gateway. Safe: got_done returns before any reset, and
|
||||
@@ -589,20 +581,14 @@ class GatewayStreamConsumer:
|
||||
if self._accumulated:
|
||||
if self._fallback_final_send:
|
||||
await self._send_fallback_final(self._accumulated)
|
||||
elif current_update_visible and (
|
||||
not self._adapter_requires_finalize
|
||||
or self._last_edit_overflowed
|
||||
elif (
|
||||
current_update_visible
|
||||
and not self._adapter_requires_finalize
|
||||
):
|
||||
# Mid-stream edit above already delivered the
|
||||
# final accumulated content. Skip the redundant
|
||||
# final edit for adapters that don't need an
|
||||
# explicit finalize signal, and for any adapter
|
||||
# when that edit split-and-delivered across
|
||||
# continuations: the split edit carried
|
||||
# finalize=True itself, and re-finalizing with
|
||||
# the full text would overflow-split again into
|
||||
# the adopted continuation, duplicating chunks
|
||||
# on screen.
|
||||
# final edit — but only for adapters that don't
|
||||
# need an explicit finalize signal.
|
||||
self._final_response_sent = True
|
||||
self._final_content_delivered = True
|
||||
elif self._message_id:
|
||||
@@ -661,21 +647,11 @@ class GatewayStreamConsumer:
|
||||
await asyncio.sleep(0.05) # Small yield to not busy-loop
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Best-effort final edit on cancellation. finalize=True so
|
||||
# REQUIRES_EDIT_FINALIZE platforms (Telegram) apply final
|
||||
# formatting — a plain edit here would leave the entire reply
|
||||
# rendered as a raw streaming preview while the success flags
|
||||
# below suppress the gateway's formatted re-send.
|
||||
# is_turn_final=False keeps _try_fresh_final from setting
|
||||
# _final_response_sent itself; this handler owns the flags.
|
||||
# Best-effort final edit on cancellation
|
||||
_best_effort_ok = False
|
||||
if self._accumulated and self._message_id:
|
||||
try:
|
||||
_best_effort_ok = bool(
|
||||
await self._send_or_edit(
|
||||
self._accumulated, finalize=True, is_turn_final=False,
|
||||
)
|
||||
)
|
||||
_best_effort_ok = bool(await self._send_or_edit(self._accumulated))
|
||||
except Exception:
|
||||
pass
|
||||
# Only confirm final delivery if the best-effort send above
|
||||
@@ -891,21 +867,11 @@ class GatewayStreamConsumer:
|
||||
self._notify_new_message()
|
||||
|
||||
# Remove the frozen partial message so the user only sees the
|
||||
# complete fallback response. ONLY safe when the fallback re-sent
|
||||
# the FULL final text (continuation == final_text). When the
|
||||
# prefix-based dedup above sent only the missing TAIL, the partial
|
||||
# message IS the head of the answer — deleting it leaves the user
|
||||
# with only the last part of the response (the "Gemini sent only
|
||||
# the second half" symptom). Best-effort — if the platform doesn't
|
||||
# complete fallback response. Best-effort — if the platform doesn't
|
||||
# implement ``delete_message``, the delete fails (flood control still
|
||||
# active, bot lacks permission, message too old to delete), the
|
||||
# partial remains but at least the full answer was delivered.
|
||||
if (
|
||||
stale_message_id
|
||||
and stale_message_id != last_message_id
|
||||
and not self._fallback_preserve_partial_messages
|
||||
and continuation == final_text
|
||||
):
|
||||
if stale_message_id and stale_message_id != last_message_id:
|
||||
delete_fn = getattr(self.adapter, "delete_message", None)
|
||||
if delete_fn is not None:
|
||||
try:
|
||||
@@ -922,7 +888,6 @@ class GatewayStreamConsumer:
|
||||
self._final_content_delivered = True
|
||||
self._last_sent_text = chunks[-1]
|
||||
self._fallback_prefix = ""
|
||||
self._fallback_preserve_partial_messages = False
|
||||
|
||||
def _is_flood_error(self, result) -> bool:
|
||||
"""Check if a SendResult failure is due to flood control / rate limiting."""
|
||||
@@ -1243,7 +1208,6 @@ class GatewayStreamConsumer:
|
||||
return True
|
||||
# Failure already disabled drafts for this run; fall through to
|
||||
# the regular edit/send path below.
|
||||
self._last_edit_overflowed = False
|
||||
try:
|
||||
if self._message_id is not None:
|
||||
if self._edit_supported:
|
||||
@@ -1300,7 +1264,6 @@ class GatewayStreamConsumer:
|
||||
and result.message_id
|
||||
and result.message_id != self._message_id
|
||||
):
|
||||
self._last_edit_overflowed = True
|
||||
self._message_id = str(result.message_id)
|
||||
self._message_created_ts = time.monotonic()
|
||||
self._last_sent_text = ""
|
||||
@@ -1311,35 +1274,6 @@ class GatewayStreamConsumer:
|
||||
self._flood_strikes = 0
|
||||
return True
|
||||
else:
|
||||
raw_response = getattr(result, "raw_response", None)
|
||||
if isinstance(raw_response, dict) and raw_response.get("partial_overflow"):
|
||||
# Telegram edited/sent one or more overflow chunks,
|
||||
# but not the complete response. Preserve the
|
||||
# visible prefix so the got_done fallback sends the
|
||||
# missing tail instead of marking a clipped topic
|
||||
# reply as final delivery.
|
||||
self._message_id = str(
|
||||
raw_response.get("last_message_id")
|
||||
or result.message_id
|
||||
or self._message_id
|
||||
)
|
||||
delivered_prefix = raw_response.get("delivered_prefix")
|
||||
if isinstance(delivered_prefix, str) and delivered_prefix:
|
||||
self._last_sent_text = delivered_prefix
|
||||
self._fallback_prefix = delivered_prefix
|
||||
self._fallback_preserve_partial_messages = text.startswith(
|
||||
delivered_prefix
|
||||
)
|
||||
else:
|
||||
self._fallback_prefix = self._visible_prefix()
|
||||
self._fallback_preserve_partial_messages = False
|
||||
self._fallback_final_send = True
|
||||
self._edit_supported = False
|
||||
self._already_sent = True
|
||||
if getattr(result, "continuation_message_ids", ()):
|
||||
self._notify_new_message()
|
||||
return False
|
||||
|
||||
# Edit failed. If this looks like flood control / rate
|
||||
# limiting, use adaptive backoff: double the edit interval
|
||||
# and retry on the next cycle. Only permanently disable
|
||||
|
||||
Reference in New Issue
Block a user