, continuation_message_ids=(...))`` so the
stream consumer can keep editing the most recent visible message
and the gateway has full visibility into every message id we put on
screen.
Falls back to ``SendResult(success=False)`` only if even the first-
chunk edit fails — that's a real adapter problem, not an overflow.
"""
chunks = self.truncate_message(
content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len,
)
if len(chunks) <= 1:
# Defensive: shouldn't happen given the caller's pre-flight, but
# if truncate_message returned a single chunk just edit normally.
chunks = [content]
# Step 1 — edit the existing message with the first chunk.
first_chunk = chunks[0]
try:
if finalize:
# Use format_message + parse_mode for the final chunk;
# mirror edit_message's main happy-path.
formatted = self.format_message(first_chunk)
try:
await self._bot.edit_message_text(
chat_id=int(chat_id),
message_id=int(message_id),
text=formatted,
parse_mode=ParseMode.MARKDOWN_V2,
)
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),
)
else:
await self._bot.edit_message_text(
chat_id=int(chat_id),
message_id=int(message_id),
text=first_chunk,
)
except Exception as e:
err_str = str(e).lower()
if "not modified" in err_str:
# First chunk identical to current text — fall through to
# send continuations.
pass
else:
logger.error(
"[%s] Overflow split: first-chunk edit failed: %s",
self.name, e, exc_info=True,
)
return SendResult(success=False, error=str(e))
# Step 2 — send each remaining chunk as a continuation message,
# threaded as a reply to the previous so the user sees them as a
# contiguous block. We call self._bot.send_message directly so the
# continuation skips ``self.send``'s own pre-chunking pass (chunks
# 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:]:
sent_msg = None
reply_to_id = int(prev_id) if prev_id else None
thread_kwargs = self._thread_kwargs_for_send(
chat_id,
thread_id,
metadata,
reply_to_message_id=reply_to_id,
)
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
sent_msg = await self._bot.send_message(
chat_id=int(chat_id),
text=text,
parse_mode=ParseMode.MARKDOWN_V2 if use_markdown else None,
reply_to_message_id=reply_to_id,
**thread_kwargs,
**self._link_preview_kwargs(),
**self._notification_kwargs(metadata),
)
break
except Exception as send_err:
if "reply message not found" in str(send_err).lower():
# Drop the reply anchor and try again. Private DM
# topic fallback needs the anchor and topic id together;
# forum topics can still safely keep message_thread_id.
retry_thread_kwargs = (
{}
if metadata and metadata.get("telegram_dm_topic_reply_fallback")
else self._thread_kwargs_for_send(
chat_id, thread_id, metadata, reply_to_message_id=None
)
)
try:
sent_msg = await self._bot.send_message(
chat_id=int(chat_id),
text=_strip_mdv2(chunk) if finalize else chunk,
**retry_thread_kwargs,
**self._link_preview_kwargs(),
**self._notification_kwargs(metadata),
)
break
except Exception as _retry_err:
logger.warning(
"[%s] Overflow continuation no-reply retry failed: %s",
self.name, _retry_err,
)
sent_msg = None
break
if use_markdown:
# try plain text on next loop iteration
continue
logger.warning(
"[%s] Overflow continuation send failed: %s",
self.name, send_err,
)
sent_msg = None
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.
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),
)
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
logger.debug(
"[%s] Overflow split delivered %d chunks; last_id=%s",
self.name, 1 + len(continuation_ids), last_id,
)
return SendResult(
success=True,
message_id=last_id,
continuation_message_ids=tuple(continuation_ids),
)
async def delete_message(self, chat_id: str, message_id: str) -> bool:
"""Delete a previously sent Telegram message.
Used by the stream consumer's fresh-final cleanup path (ported
from openclaw/openclaw#72038) to remove long-lived preview
messages after sending the completed reply as a fresh message.
Telegram's Bot API ``deleteMessage`` works for bot-posted
messages in the last 48 hours. Failures are non-fatal — the
caller leaves the preview in place and logs at debug level.
"""
if not self._bot:
return False
try:
await self._bot.delete_message(
chat_id=int(chat_id),
message_id=int(message_id),
)
return True
except Exception as e:
logger.debug(
"[%s] Failed to delete Telegram message %s: %s",
self.name, message_id, e,
)
return False
def supports_draft_streaming(
self,
chat_type: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""Telegram supports sendMessageDraft for private chats only.
Bot API 9.5 (March 2026) opened ``sendMessageDraft`` to all bots
unconditionally for private (DM) chats. Groups, supergroups, and
channels still rely on the edit-based path.
We additionally require ``self._bot`` to expose ``send_message_draft``
(added to python-telegram-bot in 22.6); older PTB installs gracefully
fall back to the edit path even on DMs.
"""
if not self._bot or not hasattr(self._bot, "send_message_draft"):
return False
return (chat_type or "").lower() in {"dm", "private"}
async def send_draft(
self,
chat_id: str,
draft_id: int,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Stream a partial message via Telegram's native draft API.
Uses ``sendRichMessageDraft`` (Bot API 10.1) with the raw markdown when
rich messages are enabled and supported, otherwise the plain-text
``sendMessageDraft``. The Bot API animates the preview when the same
``draft_id`` is reused across consecutive calls in the same chat. When
the response finishes, the caller sends the final text via the normal
``send`` path; the draft preview clears naturally on the client
(Telegram has no Bot API to "promote" a draft to a real message — the
final ``sendMessage``/``sendRichMessage`` is what the user receives in
their history).
"""
if not self._bot:
return SendResult(success=False, error="not_connected")
# Rich draft fast-path (Bot API 10.1 sendRichMessageDraft): render the
# streaming preview with the same raw markdown the final
# sendRichMessage will persist, so the animated draft matches the final
# message. Any failure degrades to the legacy plain-text draft below.
if self._should_attempt_rich_draft(content):
if await self._try_send_rich_draft(chat_id, draft_id, content, metadata):
# Drafts have no message_id; report success without one.
return SendResult(success=True, message_id=None)
if not hasattr(self._bot, "send_message_draft"):
return SendResult(success=False, error="api_unavailable")
# Trim to the same UTF-16 budget the platform enforces on regular
# sends. Drafts have the same length contract as messages.
text = content if len(content) <= self.MAX_MESSAGE_LENGTH else \
self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0]
thread_id = self._metadata_thread_id(metadata)
# Apply the same MarkdownV2 conversion the regular ``send`` path uses
# so the animated draft preview renders with identical formatting to
# the final message. Without this, the draft streams as raw text and
# the final ``sendMessage`` (which DOES use MarkdownV2) snaps into
# formatted output, producing a jarring visual shift at the end of the
# response. We try MarkdownV2 first and fall back to plain text if a
# malformed escape would be rejected — mirroring the (True, False)
# retry the streaming send loop uses — so a single bad token never
# kills draft streaming for the whole response.
for use_markdown in (True, False):
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"draft_id": int(draft_id),
"text": self.format_message(text) if use_markdown else text,
}
if use_markdown:
kwargs["parse_mode"] = ParseMode.MARKDOWN_V2
if thread_id is not None:
kwargs["message_thread_id"] = thread_id
try:
ok = await self._bot.send_message_draft(**kwargs)
if ok:
# Drafts have no message_id; we report success without one
# so the caller knows the animation frame landed.
return SendResult(success=True, message_id=None)
return SendResult(success=False, error="draft_rejected")
except Exception as e:
# A MarkdownV2 parse failure (BadRequest "can't parse entities")
# is recoverable: retry once as plain text. Any other failure
# (chat doesn't allow drafts, transient hiccup) — or a failure
# on the plain-text attempt — propagates to the caller, which
# treats it as "fall back to edit-based for this response".
if use_markdown and self._is_bad_request_error(e):
logger.debug(
"[%s] sendMessageDraft MarkdownV2 rejected, retrying "
"as plain text (chat=%s draft_id=%s): %s",
self.name, chat_id, draft_id, e,
)
continue
logger.debug(
"[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
self.name, chat_id, draft_id, e,
)
return SendResult(success=False, error=str(e))
return SendResult(success=False, error="draft_rejected")
async def _send_message_with_thread_fallback(self, **kwargs):
"""Send a Telegram message, retrying once without message_thread_id
if Telegram returns 'Message thread not found'.
Used for control-style sends (approval prompts, model picker,
update prompts) that can carry a stale thread_id from a DM
reply chain. The streaming send loop has its own equivalent
(PR #3390) at the body of ``send``; this helper applies the
same retry pattern to the non-streaming control paths.
"""
if not self._bot:
raise RuntimeError("Not connected")
message_thread_id = kwargs.get("message_thread_id")
try:
return await self._bot.send_message(**kwargs)
except Exception as send_err:
if (
message_thread_id is not None
and self._is_bad_request_error(send_err)
and self._is_thread_not_found_error(send_err)
):
logger.warning(
"[%s] Thread %s not found for control message, retrying without message_thread_id",
self.name,
message_thread_id,
)
retry_kwargs = dict(kwargs)
retry_kwargs.pop("message_thread_id", None)
return await self._bot.send_message(**retry_kwargs)
raise
async def send_update_prompt(
self, chat_id: str, prompt: str, default: str = "",
session_key: str = "",
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an inline-keyboard update prompt (Yes / No buttons).
Used by the gateway ``/update`` watcher when ``hermes update --gateway``
needs user input (stash restore, config migration).
"""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
default_hint = f" (default: {default})" if default else ""
text = self.format_message(f"⚕ *Update needs your input:*\n\n{prompt}{default_hint}")
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("✓ Yes", callback_data="update_prompt:y"),
InlineKeyboardButton("✗ No", callback_data="update_prompt:n"),
]
])
thread_id = self._metadata_thread_id(metadata)
reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode)
msg = await self._send_message_with_thread_fallback(
chat_id=int(chat_id),
text=text,
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
reply_to_message_id=reply_to_id,
**self._thread_kwargs_for_send(
chat_id,
thread_id,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
),
**self._link_preview_kwargs(),
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.warning("[%s] send_update_prompt failed: %s", self.name, e)
return SendResult(success=False, error=str(e))
async def send_exec_approval(
self, chat_id: str, command: str, session_key: str,
description: str = "dangerous command",
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an inline-keyboard approval prompt with interactive buttons.
The buttons call ``resolve_gateway_approval()`` to unblock the waiting
agent thread — same mechanism as the text ``/approve`` flow.
"""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
cmd_preview = command[:3800] + "..." if len(command) > 3800 else command
text = (
f"⚠️ Command Approval Required\n\n"
f"{_html.escape(cmd_preview)}\n\n"
f"Reason: {_html.escape(description)}"
)
# Resolve thread context for thread replies
thread_id = self._metadata_thread_id(metadata)
# We'll use the message_id as part of callback_data to look up session_key
# Send a placeholder first, then update — or use a counter.
# Simpler: use a monotonic counter to generate short IDs.
import itertools
if not hasattr(self, "_approval_counter"):
self._approval_counter = itertools.count(1)
approval_id = next(self._approval_counter)
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("✅ Allow Once", callback_data=f"ea:once:{approval_id}"),
InlineKeyboardButton("✅ Session", callback_data=f"ea:session:{approval_id}"),
],
[
InlineKeyboardButton("✅ Always", callback_data=f"ea:always:{approval_id}"),
InlineKeyboardButton("❌ Deny", callback_data=f"ea:deny:{approval_id}"),
],
])
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"text": text,
"parse_mode": ParseMode.HTML,
"reply_markup": keyboard,
**self._link_preview_kwargs(),
}
reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode)
kwargs["reply_to_message_id"] = reply_to_id
kwargs.update(
self._thread_kwargs_for_send(
chat_id,
thread_id,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
)
msg = await self._send_message_with_thread_fallback(**kwargs)
# Store session_key keyed by approval_id for the callback handler
self._approval_state[approval_id] = session_key
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.warning("[%s] send_exec_approval failed: %s", self.name, e)
return SendResult(success=False, error=str(e))
async def send_slash_confirm(
self, chat_id: str, title: str, message: str, session_key: str,
confirm_id: str, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Render a three-button slash-command confirmation prompt."""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
preview = self.format_message(message if len(message) <= 3800 else message[:3800] + "...")
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("✅ Approve Once", callback_data=f"sc:once:{confirm_id}"),
InlineKeyboardButton("🔒 Always Approve", callback_data=f"sc:always:{confirm_id}"),
],
[
InlineKeyboardButton("❌ Cancel", callback_data=f"sc:cancel:{confirm_id}"),
],
])
thread_id = self._metadata_thread_id(metadata)
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"text": preview,
"parse_mode": ParseMode.MARKDOWN_V2,
"reply_markup": keyboard,
**self._link_preview_kwargs(),
}
reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode)
kwargs["reply_to_message_id"] = reply_to_id
kwargs.update(
self._thread_kwargs_for_send(
chat_id,
thread_id,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
)
msg = await self._send_message_with_thread_fallback(**kwargs)
self._slash_confirm_state[confirm_id] = session_key
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.warning("[%s] send_slash_confirm failed: %s", self.name, e)
return SendResult(success=False, error=str(e))
async def send_clarify(
self,
chat_id: str,
question: str,
choices: Optional[list],
clarify_id: str,
session_key: str,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Render a clarify prompt with one inline button per choice.
Multi-choice mode (``choices`` non-empty): renders one button per
option plus a final "✏️ Other (type answer)" button. Picking the
"Other" button flips the entry into text-capture mode so the next
message becomes the response.
Open-ended mode (``choices`` empty): renders the question as plain
text — no buttons. The next message in the session is captured by
the gateway's text-intercept and resolves the clarify.
"""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
text = f"❓ {_html.escape(question)}"
thread_id = self._metadata_thread_id(metadata)
if choices:
# Render full option text in the message body so mobile
# users can read long choices that would be truncated in
# inline button labels. Buttons keep short numeric labels
# (1, 2, …, Other) to avoid Telegram truncation.
option_lines = "\n".join(
f"{i + 1}. {_html.escape(str(c))}"
for i, c in enumerate(choices)
)
text += f"\n\n{option_lines}"
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"text": text,
"parse_mode": ParseMode.HTML,
**self._link_preview_kwargs(),
}
if choices:
# Telegram caps callback_data at 64 bytes; keep "cl::"
# short.
rows = []
for idx in range(len(choices)):
rows.append([
InlineKeyboardButton(
str(idx + 1),
callback_data=f"cl:{clarify_id}:{idx}",
)
])
rows.append([
InlineKeyboardButton(
"✏️ Other (type answer)",
callback_data=f"cl:{clarify_id}:other",
)
])
kwargs["reply_markup"] = InlineKeyboardMarkup(rows)
reply_to_id = self._reply_to_message_id_for_send(None, metadata)
kwargs["reply_to_message_id"] = reply_to_id
kwargs.update(
self._thread_kwargs_for_send(
chat_id,
thread_id,
metadata,
reply_to_message_id=reply_to_id,
)
)
msg = await self._send_message_with_thread_fallback(**kwargs)
self._clarify_state[clarify_id] = session_key
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.warning("[%s] send_clarify failed: %s", self.name, e)
return SendResult(success=False, error=str(e))
async def send_model_picker(
self,
chat_id: str,
providers: list,
current_model: str,
current_provider: str,
session_key: str,
on_model_selected,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an interactive inline-keyboard model picker.
Two-step drill-down: provider selection → model selection.
Edits the same message in-place as the user navigates.
"""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
from hermes_cli.providers import get_label
except ImportError:
def get_label(slug):
return slug
try:
# Build provider buttons — folds provider groups (display only).
keyboard = self._build_provider_keyboard(providers)
provider_label = get_label(current_provider)
text = self.format_message(
(
f"⚙ *Model Configuration*\n\n"
f"Current model: `{current_model or 'unknown'}`\n"
f"Provider: {provider_label}\n\n"
f"Select a provider:"
)
)
thread_id = metadata.get("thread_id") if metadata else None
reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode)
msg = await self._send_message_with_thread_fallback(
chat_id=int(chat_id),
text=text,
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
reply_to_message_id=reply_to_id,
**self._thread_kwargs_for_send(
chat_id,
thread_id,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
),
**self._link_preview_kwargs(),
)
# Store picker state keyed by chat_id
self._model_picker_state[str(chat_id)] = {
"msg_id": msg.message_id,
"providers": providers,
"session_key": session_key,
"on_model_selected": on_model_selected,
"current_model": current_model,
"current_provider": current_provider,
}
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.warning("[%s] send_model_picker failed: %s", self.name, e)
return SendResult(success=False, error=str(e))
_MODEL_PAGE_SIZE = 8
def _build_provider_keyboard(self, providers: list):
"""Build the top-level provider keyboard, folding provider groups.
Provider families (Kimi/Moonshot, MiniMax, xAI Grok, ...) collapse to
a single ``mpg:`` button; tapping it drills into a member
sub-keyboard. Single providers (and groups with only one authenticated
member) render as direct ``mp:`` buttons. Grouping mirrors the
CLI ``hermes model`` picker via the shared ``group_providers`` fold,
so all surfaces stay consistent.
"""
try:
from hermes_cli.models import group_providers
except Exception:
group_providers = None
by_slug = {p.get("slug"): p for p in providers}
def _provider_button(p):
count = p.get("total_models", len(p.get("models", [])))
label = f"{p['name']} ({count})"
if p.get("is_current"):
label = f"✓ {label}"
return InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
buttons: list = []
if group_providers is not None:
for row in group_providers([p.get("slug") for p in providers]):
if row["kind"] == "group":
members = [by_slug[m] for m in row["members"] if m in by_slug]
count = sum(
m.get("total_models", len(m.get("models", []))) for m in members
)
label = f"{row['label']} ▸ ({count})"
if any(m.get("is_current") for m in members):
label = f"✓ {label}"
buttons.append(
InlineKeyboardButton(label, callback_data=f"mpg:{row['group_id']}")
)
else:
p = by_slug.get(row["slug"])
if p is not None:
buttons.append(_provider_button(p))
else:
for p in providers:
buttons.append(_provider_button(p))
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
return InlineKeyboardMarkup(rows)
def _build_model_keyboard(self, models: list, page: int) -> tuple:
"""Build paginated model buttons. Returns (keyboard, page_info_text)."""
page_size = self._MODEL_PAGE_SIZE
total = len(models)
total_pages = max(1, (total + page_size - 1) // page_size)
page = max(0, min(page, total_pages - 1))
start = page * page_size
end = min(start + page_size, total)
page_models = models[start:end]
buttons: list = []
for i, model_id in enumerate(page_models):
abs_idx = start + i
short = model_id.split("/")[-1] if "/" in model_id else model_id
if len(short) > 38:
short = short[:35] + "..."
buttons.append(
InlineKeyboardButton(short, callback_data=f"mm:{abs_idx}")
)
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
# Pagination row (if needed)
if total_pages > 1:
nav: list = []
if page > 0:
nav.append(InlineKeyboardButton("◀ Prev", callback_data=f"mg:{page - 1}"))
nav.append(InlineKeyboardButton(f"{page + 1}/{total_pages}", callback_data="mx:noop"))
if page < total_pages - 1:
nav.append(InlineKeyboardButton("Next ▶", callback_data=f"mg:{page + 1}"))
rows.append(nav)
rows.append([
InlineKeyboardButton("◀ Back", callback_data="mb"),
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
])
page_info = f" ({start + 1}–{end} of {total})" if total_pages > 1 else ""
return InlineKeyboardMarkup(rows), page_info
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:)."""
state = self._model_picker_state.get(chat_id)
if not state:
await query.answer(text="Picker expired — use /model again.")
return
try:
from hermes_cli.providers import get_label
except ImportError:
def get_label(slug):
return slug
if data.startswith("mp:"):
# --- Provider selected: show model buttons (page 0) ---
provider_slug = data[3:]
provider = next(
(p for p in state["providers"] if p["slug"] == provider_slug),
None,
)
if not provider:
await query.answer(text="Provider not found.")
return
models = provider.get("models", [])
state["selected_provider"] = provider_slug
state["selected_provider_name"] = provider.get("name", provider_slug)
state["model_list"] = models
state["model_page"] = 0
keyboard, page_info = self._build_model_keyboard(models, 0)
pname = provider.get("name", provider_slug)
total = provider.get("total_models", len(models))
shown = len(models)
extra = f"\n_{total - shown} more available — type `/model ` directly_" if total > shown else ""
await query.edit_message_text(
text=self.format_message(
(
f"⚙ *Model Configuration*\n\n"
f"Provider: *{pname}*{page_info}\n"
f"Select a model:{extra}"
)
),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
)
await query.answer()
elif data.startswith("mg:"):
# --- Page navigation ---
try:
page = int(data[3:])
except ValueError:
await query.answer(text="Invalid page.")
return
models = state.get("model_list", [])
state["model_page"] = page
keyboard, page_info = self._build_model_keyboard(models, page)
pname = state.get("selected_provider_name", "")
provider_slug = state.get("selected_provider", "")
provider = next(
(p for p in state["providers"] if p["slug"] == provider_slug),
None,
)
total = provider.get("total_models", len(models)) if provider else len(models)
shown = len(models)
extra = f"\n_{total - shown} more available — type `/model ` directly_" if total > shown else ""
await query.edit_message_text(
text=self.format_message(
(
f"⚙ *Model Configuration*\n\n"
f"Provider: *{pname}*{page_info}\n"
f"Select a model:{extra}"
)
),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
)
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:
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
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:
await query.edit_message_text(
text=self.format_message(result_text),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=None,
)
except Exception:
# Markdown parse failure — retry as plain text
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!"
)
# Clean up state
self._model_picker_state.pop(chat_id, None)
elif data.startswith("mpg:"):
# --- Provider group selected: show member providers ---
group_id = data[4:]
try:
from hermes_cli.models import PROVIDER_GROUPS
_label, _desc, member_slugs = PROVIDER_GROUPS.get(group_id, ("", "", []))
except Exception:
_label, member_slugs = "", []
by_slug = {p["slug"]: p for p in state["providers"]}
members = [by_slug[m] for m in member_slugs if m in by_slug]
if not members:
await query.answer(text="Group not found.")
return
buttons = []
for p in members:
count = p.get("total_models", len(p.get("models", [])))
label = f"{p['name']} ({count})"
if p.get("is_current"):
label = f"✓ {label}"
buttons.append(
InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
)
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
rows.append([
InlineKeyboardButton("◀ Back", callback_data="mb"),
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
])
keyboard = InlineKeyboardMarkup(rows)
await query.edit_message_text(
text=self.format_message(
(
f"⚙ *Model Configuration*\n\n"
f"Provider family: *{_label or group_id}*\n\n"
f"Select a provider:"
)
),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
)
await query.answer()
elif data == "mb":
# --- Back to provider list (folds groups) ---
keyboard = self._build_provider_keyboard(state["providers"])
try:
provider_label = get_label(state["current_provider"])
except Exception:
provider_label = state["current_provider"]
await query.edit_message_text(
text=self.format_message(
(
f"⚙ *Model Configuration*\n\n"
f"Current model: `{state['current_model'] or 'unknown'}`\n"
f"Provider: {provider_label}\n\n"
f"Select a provider:"
)
),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
)
await query.answer()
elif data == "mx":
# --- Cancel ---
self._model_picker_state.pop(chat_id, None)
await query.edit_message_text(
text="Model selection cancelled.",
reply_markup=None,
)
await query.answer()
else:
# Catch-all (e.g. page counter button "mx:noop")
await query.answer()
async def _handle_callback_query(
self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"
) -> None:
"""Handle inline keyboard button clicks."""
query = update.callback_query
if not query or not query.data:
return
data = query.data
query_message = getattr(query, "message", None)
query_chat_id = getattr(query_message, "chat_id", None)
query_chat = getattr(query_message, "chat", None)
query_chat_type = getattr(query_chat, "type", None)
query_thread_id = getattr(query_message, "message_thread_id", None)
query_user_name = getattr(query.from_user, "first_name", None)
# --- Model picker callbacks ---
if data.startswith(("mp:", "mpg:", "mm:", "mc:", "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)
return
# --- Gmail-triage callbacks (gt:verb:arg) ---
if data.startswith("gt:"):
await self._handle_gmail_triage_callback(
query,
data,
query_chat_id=query_chat_id,
query_chat_type=query_chat_type,
query_thread_id=query_thread_id,
query_user_name=query_user_name,
)
return
# --- Exec approval callbacks (ea:choice:id) ---
if data.startswith("ea:"):
parts = data.split(":", 2)
if len(parts) == 3:
choice = parts[1] # once, session, always, deny
try:
approval_id = int(parts[2])
except (ValueError, IndexError):
await query.answer(text="Invalid approval data.")
return
# Only authorized users may click approval buttons.
caller_id = str(getattr(query.from_user, "id", ""))
if not self._is_callback_user_authorized(
caller_id,
chat_id=query_chat_id,
chat_type=str(query_chat_type) if query_chat_type is not None else None,
thread_id=str(query_thread_id) if query_thread_id is not None else None,
user_name=query_user_name,
):
await query.answer(text="⛔ You are not authorized to approve commands.")
return
session_key = self._approval_state.pop(approval_id, None)
if not session_key:
await query.answer(text="This approval has already been resolved.")
return
# Map choice to human-readable label
label_map = {
"once": "✅ Approved once",
"session": "✅ Approved for session",
"always": "✅ Approved permanently",
"deny": "❌ Denied",
}
user_display = getattr(query.from_user, "first_name", "User")
label = label_map.get(choice, "Resolved")
await query.answer(text=label)
# Edit message to show decision, remove buttons
try:
await query.edit_message_text(
text=self.format_message(f"{label} by {user_display}"),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=None,
)
except Exception:
pass # non-fatal if edit fails
# Resolve the approval — unblocks the agent thread
try:
from tools.approval import resolve_gateway_approval
count = resolve_gateway_approval(session_key, choice)
logger.info(
"Telegram button resolved %d approval(s) for session %s (choice=%s, user=%s)",
count, session_key, choice, user_display,
)
except Exception as exc:
logger.error("Failed to resolve gateway approval from Telegram button: %s", exc)
count = 0
# Resume the typing indicator — paused when the approval was
# sent (gateway/run.py). The text /approve and /deny paths
# call resume_typing_for_chat here too; without it, typing
# stays paused for the rest of the turn after an inline
# button click.
if count and query_chat_id is not None:
self.resume_typing_for_chat(str(query_chat_id))
return
# --- Slash-confirm callbacks (sc:choice:confirm_id) ---
if data.startswith("sc:"):
parts = data.split(":", 2)
if len(parts) == 3:
choice = parts[1] # once, always, cancel
confirm_id = parts[2]
caller_id = str(getattr(query.from_user, "id", ""))
if not self._is_callback_user_authorized(
caller_id,
chat_id=query_chat_id,
chat_type=str(query_chat_type) if query_chat_type is not None else None,
thread_id=str(query_thread_id) if query_thread_id is not None else None,
user_name=query_user_name,
):
await query.answer(text="⛔ You are not authorized to answer this prompt.")
return
session_key = self._slash_confirm_state.pop(confirm_id, None)
if not session_key:
await query.answer(text="This prompt has already been resolved.")
return
label_map = {
"once": "✅ Approved once",
"always": "🔒 Always approve",
"cancel": "❌ Cancelled",
}
user_display = getattr(query.from_user, "first_name", "User")
label = label_map.get(choice, "Resolved")
await query.answer(text=label)
try:
await query.edit_message_text(
text=self.format_message(f"{label} by {user_display}"),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=None,
)
except Exception:
pass
# Resolve via the module-level primitive. The runner stored
# a handler keyed by session_key; we run it on the event
# loop and (if it returns a string) send it as a follow-up
# message in the same chat.
try:
from tools import slash_confirm as _slash_confirm_mod
result_text = await _slash_confirm_mod.resolve(
session_key, confirm_id, choice,
)
if result_text and query.message:
# Inherit the prompt message's topic. Supergroup forums
# use message_thread_id; Telegram private DM-topic lanes
# need both the private topic id and the prompt reply anchor.
thread_id = getattr(query.message, "message_thread_id", None)
chat = getattr(query.message, "chat", None)
chat_type = getattr(chat, "type", None)
prompt_message_id = getattr(query.message, "message_id", None)
send_kwargs: Dict[str, Any] = {
"chat_id": int(query.message.chat_id),
"text": self.format_message(result_text),
"parse_mode": ParseMode.MARKDOWN_V2,
**self._link_preview_kwargs(),
}
chat_type_value = getattr(chat_type, "value", chat_type)
is_private_chat = str(chat_type_value).lower() in {
"private",
str(ChatType.PRIVATE).lower(),
str(getattr(ChatType.PRIVATE, "value", ChatType.PRIVATE)).lower(),
}
if thread_id is not None and is_private_chat and prompt_message_id is not None:
reply_to_id = int(prompt_message_id)
send_kwargs["reply_to_message_id"] = reply_to_id
send_kwargs.update(
self._thread_kwargs_for_send(
str(query.message.chat_id),
str(thread_id),
{
"thread_id": str(thread_id),
"telegram_dm_topic_reply_fallback": True,
},
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
)
elif thread_id is not None:
send_kwargs.update(
self._thread_kwargs_for_send(
str(query.message.chat_id),
str(thread_id),
{"thread_id": str(thread_id)},
reply_to_mode=self._reply_to_mode
)
)
await self._send_message_with_thread_fallback(**send_kwargs)
except Exception as exc:
logger.error("[%s] slash-confirm callback failed: %s", self.name, exc, exc_info=True)
return
# --- Clarify callbacks (cl:clarify_id:idx | cl:clarify_id:other) ---
if data.startswith("cl:"):
parts = data.split(":", 2)
if len(parts) == 3:
clarify_id = parts[1]
choice_token = parts[2]
caller_id = str(getattr(query.from_user, "id", ""))
if not self._is_callback_user_authorized(
caller_id,
chat_id=query_chat_id,
chat_type=str(query_chat_type) if query_chat_type is not None else None,
thread_id=str(query_thread_id) if query_thread_id is not None else None,
user_name=query_user_name,
):
await query.answer(text="⛔ You are not authorized to answer this prompt.")
return
session_key = self._clarify_state.get(clarify_id)
if not session_key:
await query.answer(text="This prompt has already been resolved.")
return
user_display = getattr(query.from_user, "first_name", "User")
if choice_token == "other":
# Flip into text-capture mode and tell the user to type
# their answer. The gateway's text-intercept will pick
# up the next message in this session and resolve the
# clarify. Do NOT pop _clarify_state yet — we still
# need it if the user is slow to respond and the entry
# is cleared by something else.
try:
from tools.clarify_gateway import mark_awaiting_text
mark_awaiting_text(clarify_id)
except Exception as exc:
logger.warning("[%s] mark_awaiting_text failed: %s", self.name, exc)
await query.answer(text="✏️ Type your answer in the chat.")
try:
await query.edit_message_text(
text=f"❓ {query.message.text or ''}\n\nAwaiting typed response from {_html.escape(user_display)}…",
parse_mode=ParseMode.HTML,
reply_markup=None,
)
except Exception:
pass
return
# Numeric choice → resolve immediately with the chosen text
try:
idx = int(choice_token)
except (ValueError, TypeError):
await query.answer(text="Invalid choice.")
return
# Look up the choice text from the entry registered in the
# clarify primitive. Fall back to the index if the entry
# has been cleaned up (race with timeout / session reset).
resolved_text: Optional[str] = None
try:
from tools.clarify_gateway import _entries as _clarify_entries # type: ignore
entry = _clarify_entries.get(clarify_id)
if entry and entry.choices and 0 <= idx < len(entry.choices):
resolved_text = entry.choices[idx]
except Exception:
resolved_text = None
if resolved_text is None:
# Race: entry vanished. Echo the index as a number so
# the agent at least sees an intentional response
# rather than nothing.
resolved_text = f"choice {idx + 1}"
# Pop state and resolve
self._clarify_state.pop(clarify_id, None)
try:
from tools.clarify_gateway import resolve_gateway_clarify
resolved = resolve_gateway_clarify(clarify_id, resolved_text)
except Exception as exc:
logger.error("[%s] resolve_gateway_clarify failed: %s", self.name, exc)
resolved = False
await query.answer(text=f"✓ {resolved_text[:60]}")
try:
await query.edit_message_text(
text=f"❓ {_html.escape(query.message.text or '')}\n\n{_html.escape(user_display)}: {_html.escape(resolved_text)}",
parse_mode=ParseMode.HTML,
reply_markup=None,
)
except Exception:
pass
if resolved:
logger.info(
"Telegram clarify button resolved (id=%s, choice=%r, user=%s)",
clarify_id, resolved_text, user_display,
)
else:
logger.warning(
"Telegram clarify button: resolve_gateway_clarify returned False (id=%s)",
clarify_id,
)
return
# --- Update prompt callbacks ---
if not data.startswith("update_prompt:"):
return
answer = data.split(":", 1)[1] # "y" or "n"
caller_id = str(getattr(query.from_user, "id", ""))
if not self._is_callback_user_authorized(
caller_id,
chat_id=query_chat_id,
chat_type=str(query_chat_type) if query_chat_type is not None else None,
thread_id=str(query_thread_id) if query_thread_id is not None else None,
user_name=query_user_name,
):
await query.answer(text="⛔ You are not authorized to answer update prompts.")
return
await query.answer(text=f"Sent '{answer}' to the update process.")
# Edit the message to show the choice and remove buttons
label = "Yes" if answer == "y" else "No"
try:
await query.edit_message_text(
text=self.format_message(f"⚕ Update prompt answered: *{label}*"),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=None,
)
except Exception:
pass # non-fatal if edit fails
# Write the response file
try:
from hermes_constants import get_hermes_home
home = get_hermes_home()
response_path = home / ".update_response"
tmp = response_path.with_suffix(".tmp")
tmp.write_text(answer)
tmp.replace(response_path)
logger.info("Telegram update prompt answered '%s' by user %s",
answer, getattr(query.from_user, "id", "unknown"))
except Exception as exc:
logger.error("Failed to write update response from callback: %s", exc)
# Maps `gt:` -> (script-name, extra-args, success-label, is_state).
# Scripts live in ~/.hermes/scripts/gmail-triage/. `arg` from the callback
# data is always passed as the first positional arg.
# is_state=True means the verb is a sticky sender-rule change (mute, trust,
# vip) that should leave the keyboard tappable for follow-on actions.
# is_state=False is a per-email one-shot (send, archive, draft, spam) that
# strips the keyboard on success.
_GT_VERB_DISPATCH = {
"send": ("send-draft.sh", [], "✓ sent draft", False),
"archive": ("archive.sh", [], "✓ archived", False),
"draft": ("draft-blank.sh", [], "✓ drafted reply", False),
"spam": ("spam.sh", [], "✓ marked spam", False),
"mute": ("mute-add.sh", ["email"], "✓ muted", True),
"mute-domain": ("mute-add.sh", ["domain"], "✓ muted domain", True),
"trust": ("trusted-ops-add.sh", ["email"], "✓ trusted", True),
"trust-domain": ("trusted-ops-add.sh", ["domain"], "✓ trusted domain", True),
"vip": ("vip-add.sh", ["email"], "✓ marked VIP", True),
"vip-domain": ("vip-add.sh", ["domain"], "✓ marked VIP domain", True),
}
async def _handle_gmail_triage_callback(
self,
query,
data: str,
*,
query_chat_id,
query_chat_type,
query_thread_id,
query_user_name,
) -> None:
"""Dispatch a gmail-triage inline-button callback (gt:verb:arg)."""
parts = data.split(":", 2)
if len(parts) != 3:
await query.answer(text="Invalid gmail-triage data.")
return
verb, arg = parts[1], parts[2]
caller_id = str(getattr(query.from_user, "id", ""))
if not self._is_callback_user_authorized(
caller_id,
chat_id=query_chat_id,
chat_type=str(query_chat_type) if query_chat_type is not None else None,
thread_id=str(query_thread_id) if query_thread_id is not None else None,
user_name=query_user_name,
):
await query.answer(text="⛔ You are not authorized to act on this email.")
return
entry = self._GT_VERB_DISPATCH.get(verb)
if not entry:
await query.answer(text=f"Unknown verb: {verb}")
return
script_name, extra_args, success_label, is_state_verb = entry
script_path = _Path.home() / ".hermes" / "scripts" / "gmail-triage" / script_name
if not script_path.exists():
await query.answer(text=f"❌ {script_name} missing")
logger.error("[%s] gmail-triage script missing: %s", self.name, script_path)
return
cmd = [str(script_path), arg, *extra_args]
success = False
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_stdout_bytes, stderr_bytes = await asyncio.wait_for(
proc.communicate(), timeout=60,
)
if proc.returncode == 0:
label = success_label
success = True
logger.info(
"[%s] gmail-triage callback ok: verb=%s arg=%s",
self.name, verb, arg,
)
else:
stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
last_line = stderr_text.splitlines()[-1] if stderr_text else f"exit {proc.returncode}"
label = f"❌ {verb} failed: {last_line[:80]}"
logger.error(
"[%s] gmail-triage callback failed: verb=%s arg=%s rc=%s stderr=%s",
self.name, verb, arg, proc.returncode, stderr_text,
)
except asyncio.TimeoutError:
label = f"❌ {verb} timed out"
logger.error("[%s] gmail-triage callback timed out: verb=%s arg=%s", self.name, verb, arg)
except Exception as exc:
label = f"❌ {verb} error: {exc}"
logger.error(
"[%s] gmail-triage callback exception: verb=%s arg=%s err=%s",
self.name, verb, arg, exc, exc_info=True,
)
await query.answer(text=label)
if not success:
return
user_display = getattr(query.from_user, "first_name", "User")
original_text = (query.message.text or "") if query.message else ""
appended = f"{original_text}\n— {label} by {user_display}"
try:
if is_state_verb:
# Sticky state change: append confirmation, KEEP keyboard so
# the user can stack further actions on this email.
await query.edit_message_text(text=appended)
else:
# Per-email one-shot: strip keyboard so the action can't fire twice.
await query.edit_message_text(text=appended, reply_markup=None)
except Exception:
pass
def _missing_media_path_error(self, label: str, path: str) -> str:
"""Build an actionable file-not-found error for gateway MEDIA delivery.
Paths like /workspace/... or /output/... often only exist inside the
Docker sandbox, while the gateway process runs on the host.
"""
error = f"{label} file not found: {path}"
if path.startswith(("/workspace/", "/output/", "/outputs/")):
error += (
" (path may only exist inside the Docker sandbox. "
"Bind-mount a host directory and emit the host-visible "
"path in MEDIA: for gateway file delivery.)"
)
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,
audio_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
"""Send audio as a native Telegram voice message or audio file."""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
if not os.path.exists(audio_path):
return SendResult(success=False, error=self._missing_media_path_error("Audio", audio_path))
with open(audio_path, "rb") as audio_file:
ext = os.path.splitext(audio_path)[1].lower()
# .ogg / .opus files -> send as voice (round playable bubble)
if ext in {".ogg", ".opus"}:
_voice_thread = self._metadata_thread_id(metadata)
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
voice_thread_kwargs = self._thread_kwargs_for_send(
chat_id,
_voice_thread,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_voice,
{
"chat_id": int(chat_id),
"voice": audio_file,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
**voice_thread_kwargs,
**self._notification_kwargs(metadata),
},
metadata,
reply_to_id,
"voice",
reset_media=lambda: audio_file.seek(0),
)
elif ext in {".mp3", ".m4a"}:
# Telegram's Bot API sendAudio only accepts MP3 / M4A.
_audio_thread = self._metadata_thread_id(metadata)
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
audio_thread_kwargs = self._thread_kwargs_for_send(
chat_id,
_audio_thread,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_audio,
{
"chat_id": int(chat_id),
"audio": audio_file,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
**audio_thread_kwargs,
**self._notification_kwargs(metadata),
},
metadata,
reply_to_id,
"audio",
reset_media=lambda: audio_file.seek(0),
)
else:
# Formats Telegram can't play natively (.wav, .flac, ...)
# — fall back to document delivery instead of raising.
return await self.send_document(
chat_id=chat_id,
file_path=audio_path,
caption=caption,
reply_to=reply_to,
metadata=metadata,
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.error(
"[%s] Failed to send Telegram voice/audio, falling back to base adapter: %s",
self.name,
e,
exc_info=True,
)
return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata)
async def send_multiple_images(
self,
chat_id: str,
images: List[tuple],
metadata: Optional[Dict[str, Any]] = None,
human_delay: float = 0.0,
) -> None:
"""Send a batch of images natively via Telegram's media group API.
Telegram's ``send_media_group`` bundles up to 10 photos/videos into
a single album. Larger batches are chunked. Animated GIFs cannot
go into a media group (they require ``send_animation``), so they
are peeled off and sent individually via the base default path.
URL-based photos go into the group directly; local files are
opened as byte streams. On failure the whole batch falls back to
the base adapter's per-image loop.
"""
if not self._bot:
return
if not images:
return
try:
from telegram import InputMediaPhoto
except Exception as exc: # pragma: no cover - missing SDK
logger.warning(
"[%s] InputMediaPhoto unavailable, falling back to per-image send: %s",
self.name, exc,
)
await super().send_multiple_images(chat_id, images, metadata, human_delay)
return
# Peel off animations — they need send_animation, not send_media_group
animations: List[tuple] = []
photos: List[tuple] = []
for image_url, alt_text in images:
if not image_url.startswith("file://") and self._is_animation_url(image_url):
animations.append((image_url, alt_text))
else:
photos.append((image_url, alt_text))
# Animations: route through the base default (per-image send_animation)
if animations:
await super().send_multiple_images(
chat_id, animations, metadata, human_delay=human_delay,
)
if not photos:
return
from urllib.parse import unquote as _unquote
_thread = self._metadata_thread_id(metadata)
# Chunk into groups of 10 (Telegram's album limit)
CHUNK = 10
chunks = [photos[i:i + CHUNK] for i in range(0, len(photos), CHUNK)]
for chunk_idx, chunk in enumerate(chunks):
if human_delay > 0 and chunk_idx > 0:
await asyncio.sleep(human_delay)
media: List[Any] = []
opened_files: List[Any] = []
try:
for image_url, alt_text in chunk:
caption = alt_text[:1024] if alt_text else None
if image_url.startswith("file://"):
local_path = _unquote(image_url[7:])
if not os.path.exists(local_path):
logger.warning(
"[%s] Skipping missing image in media group: %s",
self.name, local_path,
)
continue
fh = open(local_path, "rb")
opened_files.append(fh)
media.append(InputMediaPhoto(media=fh, caption=caption))
else:
media.append(InputMediaPhoto(media=image_url, caption=caption))
if not media:
continue
logger.info(
"[%s] Sending media group of %d photo(s) (chunk %d/%d)",
self.name, len(media), chunk_idx + 1, len(chunks),
)
reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode)
thread_kwargs = self._thread_kwargs_for_send(
chat_id,
_thread,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
def _reset_opened_files() -> None:
for fh in opened_files:
try:
fh.seek(0)
except Exception:
pass
await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_media_group,
{
"chat_id": int(chat_id),
"media": media,
"reply_to_message_id": reply_to_id,
**thread_kwargs,
**self._notification_kwargs(metadata),
},
metadata,
reply_to_id,
"media group",
reset_media=_reset_opened_files,
)
except Exception as e:
logger.warning(
"[%s] send_media_group failed (chunk %d/%d), falling back to per-image: %s",
self.name, chunk_idx + 1, len(chunks), e,
exc_info=True,
)
# Fallback: send each photo in this chunk individually
await super().send_multiple_images(
chat_id, chunk, metadata, human_delay=human_delay,
)
finally:
for fh in opened_files:
try:
fh.close()
except Exception:
pass
async def send_image_file(
self,
chat_id: str,
image_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
"""Send a local image file natively as a Telegram photo."""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
if not os.path.exists(image_path):
return SendResult(success=False, error=self._missing_media_path_error("Image", image_path))
_thread = self._metadata_thread_id(metadata)
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
thread_kwargs = self._thread_kwargs_for_send(
chat_id,
_thread,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
with open(image_path, "rb") as image_file:
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_photo,
{
"chat_id": int(chat_id),
"photo": image_file,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
**thread_kwargs,
**self._notification_kwargs(metadata),
},
metadata,
reply_to_id,
"photo",
reset_media=lambda: image_file.seek(0),
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
error_str = str(e)
# Dimension-related errors are the expected case for valid image
# files that Telegram just refuses as photos (screenshots, extreme
# aspect ratios). Log at INFO because the document fallback is
# the correct path. Any other send_photo failure also falls back
# to document (rate limits, corrupt file markers, format edge
# cases), but at WARNING because it's unexpected and worth
# surfacing in logs.
is_dim_error = (
"Photo_invalid_dimensions" in error_str
or "PHOTO_INVALID_DIMENSIONS" in error_str
)
if is_dim_error:
logger.info(
"[%s] Image dimensions exceed Telegram photo limits, "
"sending as document: %s",
self.name,
image_path,
)
else:
logger.warning(
"[%s] Failed to send Telegram local image as photo, "
"trying document fallback: %s",
self.name,
e,
exc_info=True,
)
# Fallback to sending as document (file) — no dimension limit,
# only 50MB size limit. If even that fails, fall back to the
# base adapter's text-only "Image: /path" rendering.
try:
return await self.send_document(
chat_id=chat_id,
file_path=image_path,
caption=caption,
file_name=os.path.basename(image_path),
reply_to=reply_to,
metadata=metadata,
)
except Exception as doc_err:
logger.error(
"[%s] Failed to send Telegram local image as document, "
"falling back to base adapter: %s",
self.name,
doc_err,
exc_info=True,
)
return await super().send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata)
async def send_document(
self,
chat_id: str,
file_path: str,
caption: Optional[str] = None,
file_name: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
"""Send a document/file natively as a Telegram file attachment."""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
if not os.path.exists(file_path):
return SendResult(success=False, error=self._missing_media_path_error("File", file_path))
display_name = file_name or os.path.basename(file_path)
_thread = self._metadata_thread_id(metadata)
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
thread_kwargs = self._thread_kwargs_for_send(
chat_id,
_thread,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
with open(file_path, "rb") as f:
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_document,
{
"chat_id": int(chat_id),
"document": f,
"filename": display_name,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
**thread_kwargs,
**self._notification_kwargs(metadata),
},
metadata,
reply_to_id,
"document",
reset_media=lambda: f.seek(0),
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.warning("[%s] Failed to send document: %s", self.name, e, exc_info=True)
return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata)
async def send_video(
self,
chat_id: str,
video_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
"""Send a video natively as a Telegram video message."""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
if not os.path.exists(video_path):
return SendResult(success=False, error=self._missing_media_path_error("Video", video_path))
_thread = self._metadata_thread_id(metadata)
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
thread_kwargs = self._thread_kwargs_for_send(
chat_id,
_thread,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
with open(video_path, "rb") as f:
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_video,
{
"chat_id": int(chat_id),
"video": f,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
**thread_kwargs,
**self._notification_kwargs(metadata),
},
metadata,
reply_to_id,
"video",
reset_media=lambda: f.seek(0),
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.warning("[%s] Failed to send video: %s", self.name, e, exc_info=True)
return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata)
async def send_image(
self,
chat_id: str,
image_url: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an image natively as a Telegram photo.
Tries URL-based send first (fast, works for <5MB images).
Falls back to downloading and uploading as file (supports up to 10MB).
"""
if not self._bot:
return SendResult(success=False, error="Not connected")
from tools.url_safety import is_safe_url
if not is_safe_url(image_url):
logger.warning("[%s] Blocked unsafe image URL (SSRF protection)", self.name)
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)
try:
# Telegram can send photos directly from URLs (up to ~5MB)
_photo_thread = self._metadata_thread_id(metadata)
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
photo_thread_kwargs = self._thread_kwargs_for_send(
chat_id,
_photo_thread,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_photo,
{
"chat_id": int(chat_id),
"photo": image_url,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
**photo_thread_kwargs,
**self._notification_kwargs(metadata),
},
metadata,
reply_to_id,
"URL photo",
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.warning(
"[%s] URL-based send_photo failed, trying file upload: %s",
self.name,
e,
exc_info=True,
)
# Fallback: download and upload as file (supports up to 10MB)
try:
import httpx
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(image_url)
resp.raise_for_status()
image_data = resp.content
upload_thread_kwargs = self._thread_kwargs_for_send(
chat_id,
_photo_thread,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_photo,
{
"chat_id": int(chat_id),
"photo": image_data,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
**upload_thread_kwargs,
**self._notification_kwargs(metadata),
},
metadata,
reply_to_id,
"uploaded photo",
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e2:
logger.error(
"[%s] File upload send_photo also failed: %s",
self.name,
e2,
exc_info=True,
)
# Final fallback: send URL as text
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)
async def send_animation(
self,
chat_id: str,
animation_url: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an animated GIF natively as a Telegram animation (auto-plays inline)."""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
_anim_thread = self._metadata_thread_id(metadata)
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
animation_thread_kwargs = self._thread_kwargs_for_send(
chat_id,
_anim_thread,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_animation,
{
"chat_id": int(chat_id),
"animation": animation_url,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
**animation_thread_kwargs,
**self._notification_kwargs(metadata),
},
metadata,
reply_to_id,
"animation",
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.error(
"[%s] Failed to send Telegram animation, falling back to photo: %s",
self.name,
e,
exc_info=True,
)
# Fallback: try as a regular photo
return await self.send_image(chat_id, animation_url, caption, reply_to, metadata=metadata)
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
"""Send typing indicator."""
if self._bot:
_is_dm_topic: bool = False
message_thread_id: Optional[int] = None
try:
_typing_thread = self._metadata_thread_id(metadata)
_is_dm_topic = bool(metadata and metadata.get("telegram_dm_topic_reply_fallback"))
message_thread_id = self._message_thread_id_for_typing(_typing_thread)
await self._bot.send_chat_action(
chat_id=int(chat_id),
action="typing",
message_thread_id=message_thread_id,
)
except Exception as e:
# For DM topic lanes, Telegram may reject message_thread_id.
# Fall back to sending typing without thread_id so the typing
# indicator at least appears in the main DM view.
if _is_dm_topic and message_thread_id is not None:
try:
await self._bot.send_chat_action(
chat_id=int(chat_id),
action="typing",
)
return
except Exception:
pass
# Typing failures are non-fatal; log at debug level only.
logger.debug(
"[%s] Failed to send Telegram typing indicator: %s",
self.name,
e,
exc_info=True,
)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Get information about a Telegram chat."""
if not self._bot:
return {"name": "Unknown", "type": "dm"}
try:
chat = await self._bot.get_chat(int(chat_id))
chat_type = "dm"
if chat.type == ChatType.GROUP:
chat_type = "group"
elif chat.type == ChatType.SUPERGROUP:
chat_type = "group"
if chat.is_forum:
chat_type = "forum"
elif chat.type == ChatType.CHANNEL:
chat_type = "channel"
return {
"name": chat.title or chat.full_name or str(chat_id),
"type": chat_type,
"username": chat.username,
"is_forum": getattr(chat, "is_forum", False),
}
except Exception as e:
logger.error(
"[%s] Failed to get Telegram chat info for %s: %s",
self.name,
chat_id,
e,
exc_info=True,
)
return {"name": str(chat_id), "type": "dm", "error": str(e)}
def format_message(self, content: str) -> str:
"""
Convert standard markdown to Telegram MarkdownV2 format.
Protected regions (code blocks, inline code) are extracted first so
their contents are never modified. Standard markdown constructs
(headers, bold, italic, links) are translated to MarkdownV2 syntax,
and all remaining special characters are escaped.
"""
if not content:
return content
placeholders: dict = {}
counter = [0]
def _ph(value: str) -> str:
"""Stash *value* behind a placeholder token that survives escaping."""
key = f"\x00PH{counter[0]}\x00"
counter[0] += 1
placeholders[key] = value
return key
text = content
# 0) Rewrite GFM-style pipe tables into Telegram-friendly row groups
# before the normal MarkdownV2 conversions run.
text = _wrap_markdown_tables(text)
# 1) Protect fenced code blocks (``` ... ```)
# Per MarkdownV2 spec, \ and ` inside pre/code must be escaped.
def _protect_fenced(m):
raw = m.group(0)
# Split off opening ``` (with optional language) and closing ```
open_end = raw.index('\n') + 1 if '\n' in raw[3:] else 3
opening = raw[:open_end]
body_and_close = raw[open_end:]
body = body_and_close[:-3]
body = body.replace('\\', '\\\\').replace('`', '\\`')
return _ph(opening + body + '```')
text = re.sub(
r'(```(?:[^\n]*\n)?[\s\S]*?```)',
_protect_fenced,
text,
)
# 2) Protect inline code (`...`)
# Escape \ inside inline code per MarkdownV2 spec.
text = re.sub(
r'(`[^`]+`)',
lambda m: _ph(m.group(0).replace('\\', '\\\\')),
text,
)
# 3) Convert markdown links – escape the display text; inside the URL
# only ')' and '\' need escaping per the MarkdownV2 spec.
def _convert_link(m):
display = _escape_mdv2(m.group(1))
url = m.group(2).replace('\\', '\\\\').replace(')', '\\)')
return _ph(f'[{display}]({url})')
text = re.sub(r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)', _convert_link, text)
# 4) Convert markdown headers (## Title) → bold *Title*
def _convert_header(m):
inner = m.group(1).strip()
# Strip redundant bold markers that may appear inside a header
inner = re.sub(r'\*\*(.+?)\*\*', r'\1', inner)
return _ph(f'*{_escape_mdv2(inner)}*')
text = re.sub(
r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE
)
# 5) Convert bold: **text** → *text* (MarkdownV2 bold)
text = re.sub(
r'\*\*(.+?)\*\*',
lambda m: _ph(f'*{_escape_mdv2(m.group(1))}*'),
text,
)
# 6) Convert italic: *text* (single asterisk) → _text_ (MarkdownV2 italic)
# [^*\n]+ prevents matching across newlines (which would corrupt
# bullet lists using * markers and multi-line content).
text = re.sub(
r'\*([^*\n]+)\*',
lambda m: _ph(f'_{_escape_mdv2(m.group(1))}_'),
text,
)
# 7) Convert strikethrough: ~~text~~ → ~text~ (MarkdownV2)
text = re.sub(
r'~~(.+?)~~',
lambda m: _ph(f'~{_escape_mdv2(m.group(1))}~'),
text,
)
# 8) Convert spoiler: ||text|| → ||text|| (protect from | escaping)
text = re.sub(
r'\|\|(.+?)\|\|',
lambda m: _ph(f'||{_escape_mdv2(m.group(1))}||'),
text,
)
# 9) Convert blockquotes: > at line start → protect > from escaping
# Handle both regular blockquotes (> text) and expandable blockquotes
# (Telegram MarkdownV2: **> for expandable start, || to end the quote)
def _convert_blockquote(m):
prefix = m.group(1) # >, >>, >>>, **>, or **>> etc.
content = m.group(2)
# Check if content ends with || (expandable blockquote end marker)
# In this case, preserve the trailing || unescaped for Telegram
if prefix.startswith('**') and content.endswith('||'):
return _ph(f'{prefix} {_escape_mdv2(content[:-2])}||')
return _ph(f'{prefix} {_escape_mdv2(content)}')
text = re.sub(
r'^((?:\*\*)?>{1,3}) (.+)$',
_convert_blockquote,
text,
flags=re.MULTILINE,
)
# 10) Escape remaining special characters in plain text
text = _escape_mdv2(text)
# 11) Restore placeholders in reverse insertion order so that
# nested references (a placeholder inside another) resolve correctly.
for key in reversed(list(placeholders.keys())):
text = text.replace(key, placeholders[key])
# 12) Safety net: escape unescaped ( ) { } that slipped through
# placeholder processing. Split the text into code/non-code
# segments so we never touch content inside ``` or ` spans.
_code_split = re.split(r'(```[\s\S]*?```|`[^`]+`)', text)
_safe_parts = []
for _idx, _seg in enumerate(_code_split):
if _idx % 2 == 1:
# Inside code span/block — leave untouched
_safe_parts.append(_seg)
else:
# Outside code — escape bare ( ) { }
def _esc_bare(m, _seg=_seg):
s = m.start()
ch = m.group(0)
# Already escaped
if s > 0 and _seg[s - 1] == '\\':
return ch
# ( that opens a MarkdownV2 link [text](url)
if ch == '(' and s > 0 and _seg[s - 1] == ']':
return ch
# ) that closes a link URL
if ch == ')':
before = _seg[:s]
if '](http' in before or '](' in before:
# Check depth
depth = 0
for j in range(s - 1, max(s - 2000, -1), -1):
if _seg[j] == '(':
depth -= 1
if depth < 0:
if j > 0 and _seg[j - 1] == ']':
return ch
break
elif _seg[j] == ')':
depth += 1
return '\\' + ch
_safe_parts.append(re.sub(r'[(){}]', _esc_bare, _seg))
text = ''.join(_safe_parts)
return text
# ── Group mention gating ──────────────────────────────────────────────
def _telegram_require_mention(self) -> bool:
"""Return whether group chats should require an explicit bot trigger."""
configured = self.config.extra.get("require_mention")
if configured is not None:
if isinstance(configured, str):
return configured.lower() in {"true", "1", "yes", "on"}
return bool(configured)
return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"}
def _telegram_observe_unmentioned_group_messages(self) -> bool:
"""Return whether skipped unmentioned group messages are stored as context.
When enabled with ``require_mention``, Telegram matches the Yuanbao /
OpenClaw-style group UX: observe ordinary group chatter in the session
transcript, but only dispatch the agent when the bot is explicitly
addressed.
"""
configured = self.config.extra.get("observe_unmentioned_group_messages")
if configured is None:
configured = self.config.extra.get("ingest_unmentioned_group_messages")
if configured is not None:
if isinstance(configured, str):
return configured.lower() in {"true", "1", "yes", "on"}
return bool(configured)
return os.getenv("TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES", "false").lower() in {"true", "1", "yes", "on"}
def _telegram_guest_mode(self) -> bool:
"""Return whether non-allowlisted groups may trigger via direct @mention."""
configured = self.config.extra.get("guest_mode")
if configured is not None:
if isinstance(configured, str):
return configured.lower() in {"true", "1", "yes", "on"}
return bool(configured)
return os.getenv("TELEGRAM_GUEST_MODE", "false").lower() in {"true", "1", "yes", "on"}
def _telegram_exclusive_bot_mentions(self) -> bool:
"""Return whether explicit @...bot mentions exclusively route group messages."""
configured = self.config.extra.get("exclusive_bot_mentions")
if configured is not None:
if isinstance(configured, str):
return configured.lower() in {"true", "1", "yes", "on"}
return bool(configured)
return os.getenv("TELEGRAM_EXCLUSIVE_BOT_MENTIONS", "true").lower() in {"true", "1", "yes", "on"}
def _telegram_free_response_chats(self) -> set[str]:
raw = self.config.extra.get("free_response_chats")
if raw is None:
raw = os.getenv("TELEGRAM_FREE_RESPONSE_CHATS", "")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _telegram_allowed_chats(self) -> set[str]:
"""Return the whitelist of group/supergroup chat IDs the bot will respond in.
When non-empty, group messages from chats NOT in this set are
silently ignored unless ``guest_mode`` is enabled and the bot is
explicitly @mentioned. DMs are never filtered.
Empty set means no restriction (fully backward compatible).
"""
raw = self.config.extra.get("allowed_chats")
if raw is None:
raw = os.getenv("TELEGRAM_ALLOWED_CHATS", "")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _telegram_group_allowed_chats(self) -> set[str]:
"""Return Telegram chats authorized at group scope."""
raw = self.config.extra.get("group_allowed_chats")
if raw is None:
raw = os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS", "")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _telegram_observe_allowed_chats(self) -> set[str]:
"""Chats where observed group context may use a shared source.
``group_allowed_chats`` is the gateway authorization allowlist for
user-less group sources. ``allowed_chats`` remains an optional response
gate; when set, observed context must satisfy both lists.
"""
group_allowed = self._telegram_group_allowed_chats()
if not group_allowed:
return set()
response_allowed = self._telegram_allowed_chats()
if response_allowed:
return group_allowed & response_allowed
return group_allowed
def _telegram_allowed_topics(self) -> set[str]:
"""Return the whitelist of Telegram forum topic IDs this bot handles.
When non-empty, group/supergroup messages from other topics are
silently ignored. DMs are never filtered by topic. Telegram may omit
``message_thread_id`` for the forum General topic, so ``None`` is
treated as topic ``1`` for matching purposes.
"""
raw = self.config.extra.get("allowed_topics")
if raw is None:
raw = os.getenv("TELEGRAM_ALLOWED_TOPICS", "")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _telegram_ignored_threads(self) -> set[int]:
raw = self.config.extra.get("ignored_threads")
if raw is None:
raw = os.getenv("TELEGRAM_IGNORED_THREADS", "")
if isinstance(raw, list):
values = raw
else:
values = str(raw).split(",")
ignored: set[int] = set()
for value in values:
text = str(value).strip()
if not text:
continue
try:
ignored.add(int(text))
except (TypeError, ValueError):
logger.warning("[%s] Ignoring invalid Telegram thread id: %r", self.name, value)
return ignored
def _compile_mention_patterns(self) -> List[re.Pattern]:
"""Compile optional regex wake-word patterns for group triggers."""
patterns = self.config.extra.get("mention_patterns")
if patterns is None:
raw = os.getenv("TELEGRAM_MENTION_PATTERNS", "").strip()
if raw:
try:
loaded = json.loads(raw)
except Exception:
loaded = [part.strip() for part in raw.splitlines() if part.strip()]
if not loaded:
loaded = [part.strip() for part in raw.split(",") if part.strip()]
patterns = loaded
if patterns is None:
return []
if isinstance(patterns, str):
patterns = [patterns]
if not isinstance(patterns, list):
logger.warning(
"[%s] telegram mention_patterns must be a list or string; got %s",
self.name,
type(patterns).__name__,
)
return []
compiled: List[re.Pattern] = []
for pattern in patterns:
if not isinstance(pattern, str) or not pattern.strip():
continue
try:
compiled.append(re.compile(pattern, re.IGNORECASE))
except re.error as exc:
logger.warning("[%s] Invalid Telegram mention pattern %r: %s", self.name, pattern, exc)
if compiled:
logger.info("[%s] Loaded %d Telegram mention pattern(s)", self.name, len(compiled))
return compiled
def _is_group_chat(self, message: Message) -> bool:
chat = getattr(message, "chat", None)
if not chat:
return False
chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower()
return chat_type in {"group", "supergroup"}
def _is_reply_to_bot(self, message: Message) -> bool:
if not self._bot or not getattr(message, "reply_to_message", None):
return False
reply_user = getattr(message.reply_to_message, "from_user", None)
return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None))
@staticmethod
def _extract_bot_mention_usernames(message: Message) -> set[str]:
"""Extract explicit Telegram bot usernames mentioned in text/captions.
Telegram bot usernames are 5-32 characters and must end in "bot".
Entity mentions are authoritative. The raw-text fallback is intentionally narrow so
entity-less mobile/client variants still work without treating email
addresses or arbitrary substrings as bot mentions.
"""
mentioned_bot_usernames: set[str] = set()
def _iter_sources():
yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
for source_text, entities in _iter_sources():
for entity in entities:
entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
if entity_type not in {"mention", "bot_command"}:
continue
offset = int(getattr(entity, "offset", -1))
length = int(getattr(entity, "length", 0))
if offset < 0 or length <= 0:
continue
entity_text = source_text[offset:offset + length].strip()
if entity_type == "mention":
handle = entity_text.lstrip("@").lower()
if re.fullmatch(r"[a-z0-9_]{2,29}bot", handle, re.IGNORECASE):
mentioned_bot_usernames.add(handle)
continue
# Telegram emits /cmd@botname as one bot_command entity, not as
# a separate mention entity. Treat that suffix as an explicit
# bot address for exclusive multi-bot routing even when the
# group has require_mention/free-response disabled.
at_index = entity_text.find("@")
if at_index < 0:
continue
command_target = entity_text[at_index + 1:].strip().lower()
if re.fullmatch(r"[a-z0-9_]{2,29}bot", command_target, re.IGNORECASE):
mentioned_bot_usernames.add(command_target)
# Entity-less fallback for older/client-specific updates. If Telegram
# supplied entities for a source, trust them and do not regex-rescue
# malformed/URL/code spans that the server did not mark as mentions.
for raw_text, entities in _iter_sources():
if not raw_text or entities:
continue
for match in re.finditer(r"(?i)(? bool:
if not self._bot:
return False
bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
bot_id = getattr(self._bot, "id", None)
expected = f"@{bot_username}" if bot_username else None
def _iter_sources():
yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
# Telegram parses mentions server-side and emits MessageEntity objects
# (type=mention for @username, type=text_mention for @FirstName targeting
# a user without a public username). Those entities are authoritative:
# raw substring matches like "foo@hermes_bot.example" are not mentions
# (bug #12545). Entities also correctly handle @handles inside URLs, code
# blocks, and quoted text, where a regex scan would over-match.
for source_text, entities in _iter_sources():
for entity in entities:
entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
if entity_type == "mention" and expected:
offset = int(getattr(entity, "offset", -1))
length = int(getattr(entity, "length", 0))
if offset < 0 or length <= 0:
continue
if source_text[offset:offset + length].strip().lower() == expected:
return True
elif entity_type == "text_mention":
user = getattr(entity, "user", None)
if user and getattr(user, "id", None) == bot_id:
return True
elif entity_type == "bot_command" and expected:
# Telegram's official group-disambiguation form for slash
# commands (``/cmd@botname``) is emitted as a single
# ``bot_command`` entity covering the whole span — there
# is no accompanying ``mention`` entity. Treat it as a
# direct address to this bot when the ``@botname`` suffix
# matches. This is the form Telegram's own command menu
# autocomplete produces in groups, so dropping it at the
# mention gate would break /new, /reset, /help, ... for
# every group that has ``require_mention`` enabled (#15415).
offset = int(getattr(entity, "offset", -1))
length = int(getattr(entity, "length", 0))
if offset < 0 or length <= 0:
continue
command_text = source_text[offset:offset + length]
at_index = command_text.find("@")
if at_index < 0:
continue
if command_text[at_index:].strip().lower() == expected:
return True
if bot_username and re.fullmatch(r"[a-z0-9_]{2,29}bot", bot_username, re.IGNORECASE):
return bot_username in self._extract_bot_mention_usernames(message)
return False
def _explicit_bot_mentions_exclude_self(self, message: Message) -> bool:
"""Return True when explicit bot handles target other bots, not this one.
Telegram groups can contain several Hermes bot profiles. A message like
``@bot3 hi @bot4`` must not wake ``@bot1`` through reply/wake-word
fallbacks. Treat explicit bot-handle mentions as an exclusive routing
hint: if at least one @...bot username is present and none matches this
adapter's own bot username, this adapter should ignore the message.
MessageEntity values are preferred, but some Telegram clients expose
selected bot handles as plain text in group messages. The raw-text
fallback is intentionally limited to usernames ending in "bot", which
Telegram requires for bot accounts.
"""
if not self._bot:
return False
bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
if not bot_username:
return False
mentioned_bot_usernames = self._extract_bot_mention_usernames(message)
return bool(mentioned_bot_usernames) and bot_username not in mentioned_bot_usernames
def _message_matches_mention_patterns(self, message: Message) -> bool:
if not self._mention_patterns:
return False
for candidate in (getattr(message, "text", None), getattr(message, "caption", None)):
if not candidate:
continue
for pattern in self._mention_patterns:
if pattern.search(candidate):
return True
return False
def _is_guest_mention(self, message: Message) -> bool:
"""Return True for the narrow guest-mode bypass: explicit bot mention.
The caller (:meth:`_should_process_message`) has already verified
the message is a group chat, so that check is not repeated here.
"""
return self._telegram_guest_mode() and self._message_mentions_bot(message)
def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]:
if not text or not self._bot or not getattr(self._bot, "username", None):
return text
username = re.escape(self._bot.username)
cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip()
return cleaned or text
def _should_observe_unmentioned_group_message(self, message: Message) -> bool:
"""Return True when a group message should be stored but not dispatched."""
if not self._telegram_observe_unmentioned_group_messages():
return False
if not self._is_group_chat(message):
return False
thread_id = getattr(message, "message_thread_id", None)
allowed_topics = self._telegram_allowed_topics()
if allowed_topics:
topic_id = str(thread_id) if thread_id is not None else self._GENERAL_TOPIC_THREAD_ID
if topic_id not in allowed_topics:
return False
if thread_id is not None:
try:
if int(thread_id) in self._telegram_ignored_threads():
return False
except (TypeError, ValueError):
return False
chat_id_str = str(getattr(getattr(message, "chat", None), "id", ""))
if self._telegram_exclusive_bot_mentions() and self._explicit_bot_mentions_exclude_self(message):
return False
allowed = self._telegram_observe_allowed_chats()
# Observed context is shared at chat/topic scope so a later trigger from
# another user can see it. Require an explicit chat allowlist; that
# keeps shared observed history limited to operator-approved groups and
# lets gateway authorization pass even after the shared session source
# drops the per-sender user_id.
if not allowed or chat_id_str not in allowed:
return False
# Only observe messages skipped by the require_mention gate. If the
# message would be processed normally, let the dispatcher handle it;
# if require_mention is disabled, every group message is a request.
if chat_id_str in self._telegram_free_response_chats():
return False
if not self._telegram_require_mention():
return False
if self._is_reply_to_bot(message):
return False
if self._message_mentions_bot(message):
return False
if self._message_matches_mention_patterns(message):
return False
return True
def _telegram_group_observe_shared_source(self, source):
"""Return a chat/topic-scoped source for observed Telegram group context."""
return dataclasses.replace(source, user_id=None, user_name=None, user_id_alt=None)
def _telegram_group_observe_attributed_text(self, event: MessageEvent) -> str:
user_id = event.source.user_id or "unknown"
sender = event.source.user_name or user_id
return f"[{sender}|{user_id}]\n{event.text or ''}"
def _telegram_group_observe_channel_prompt(self) -> str:
username = getattr(getattr(self, "_bot", None), "username", None) or "unknown"
bot_id = getattr(getattr(self, "_bot", None), "id", None) or "unknown"
return (
"You are handling a Telegram group chat message.\n"
f"- Your identity: user_id={bot_id}, @-mention name in this group=@{username}\n"
"- observed Telegram group context may be provided in a separate context-only block "
"before the current message; it is not necessarily addressed to you.\n"
"- Treat only the current new message as a request explicitly directed at you, "
"and use observed context only when the current message asks for it."
)
def _apply_telegram_group_observe_attribution(self, event: MessageEvent) -> MessageEvent:
"""Align triggered group turns with observed-history attribution."""
if not self._telegram_observe_unmentioned_group_messages():
return event
raw_message = getattr(event, "raw_message", None)
if not raw_message or not self._is_group_chat(raw_message):
return event
chat_id_str = str(getattr(getattr(raw_message, "chat", None), "id", ""))
allowed = self._telegram_observe_allowed_chats()
if not allowed or chat_id_str not in allowed:
return event
shared_source = self._telegram_group_observe_shared_source(event.source)
observe_prompt = self._telegram_group_observe_channel_prompt()
channel_prompt = f"{event.channel_prompt}\n\n{observe_prompt}" if event.channel_prompt else observe_prompt
if event.message_type == MessageType.COMMAND:
return dataclasses.replace(
event,
source=shared_source,
channel_prompt=channel_prompt,
)
return dataclasses.replace(
event,
text=self._telegram_group_observe_attributed_text(event),
source=shared_source,
channel_prompt=channel_prompt,
)
def _media_message_type(self, msg: Message) -> MessageType:
"""Classify a Telegram media message into a MessageType."""
if msg.sticker:
return MessageType.STICKER
if msg.photo:
return MessageType.PHOTO
if msg.video:
return MessageType.VIDEO
if msg.audio:
return MessageType.AUDIO
if msg.voice:
return MessageType.VOICE
return MessageType.DOCUMENT
async def _cache_observed_media(self, msg: Message, event: MessageEvent) -> None:
"""Cache an unmentioned group attachment and annotate the observed text.
Passive group traffic, so downloads are bounded by the same
``_max_doc_bytes`` limit as the addressed document path. Oversized or
unsupported attachments are noted in the transcript without downloading.
"""
from gateway.platforms.base import cache_media_bytes
source, filename, mime, kind = self._observed_media_source(msg)
if source is None:
return
max_bytes = getattr(self, "_max_doc_bytes", 20 * 1024 * 1024)
file_size = getattr(source, "file_size", None)
try:
size = int(file_size or 0)
except (TypeError, ValueError):
size = 0
if not (0 < size <= max_bytes):
limit_mb = max_bytes // (1024 * 1024)
event.text = self._append_observed_note(
event.text,
f"[Observed Telegram attachment too large or unverifiable. Maximum: {limit_mb} MB.]",
)
logger.info("[Telegram] Observed group attachment skipped (size=%s)", file_size)
return
try:
file_obj = await source.get_file()
data = bytes(await file_obj.download_as_bytearray())
if not filename:
filename = os.path.basename(getattr(file_obj, "file_path", "") or "")
cached = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind)
except Exception as exc:
logger.warning("[Telegram] Failed to cache observed group media: %s", exc, exc_info=True)
return
if cached is None:
event.text = self._append_observed_note(
event.text, "[Observed Telegram attachment: unsupported type, not cached.]"
)
return
event.media_urls = [cached.path]
event.media_types = [cached.media_type]
if cached.kind == "image":
event.message_type = MessageType.PHOTO
elif cached.kind == "video":
event.message_type = MessageType.VIDEO
event.text = self._append_observed_note(event.text, cached.context_note())
logger.info("[Telegram] Cached observed group %s at %s", cached.kind, cached.path)
async def _cache_replied_media(self, msg: Any, event: MessageEvent) -> None:
"""Cache media from the message this turn replies to, if any."""
from gateway.platforms.base import cache_media_bytes
reply_msg = getattr(msg, "reply_to_message", None)
if reply_msg is None:
return
source, filename, mime, kind = self._observed_media_source(reply_msg)
if source is None:
return
max_bytes = getattr(self, "_max_doc_bytes", 20 * 1024 * 1024)
file_size = getattr(source, "file_size", None)
try:
size = int(file_size or 0)
except (TypeError, ValueError):
size = 0
if not (0 < size <= max_bytes):
return
try:
file_obj = await source.get_file()
data = bytes(await file_obj.download_as_bytearray())
if not filename:
filename = os.path.basename(getattr(file_obj, "file_path", "") or "")
cached = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind)
except Exception as exc:
logger.warning("[Telegram] Failed to cache replied-to media: %s", exc, exc_info=True)
return
if cached is None:
return
event.media_urls.append(cached.path)
event.media_types.append(cached.media_type)
if len(event.media_urls) == 1:
if cached.kind == "image":
event.message_type = MessageType.PHOTO
elif cached.kind == "video":
event.message_type = MessageType.VIDEO
event.text = self._append_observed_note(
event.text,
f"[Replied-to {cached.kind} '{cached.display_name}' saved at: {cached.path}]",
)
logger.info("[Telegram] Cached replied-to %s at %s", cached.kind, cached.path)
def _observed_media_source(self, msg: Message):
"""Return (telegram_file_source, filename, mime, default_kind) or Nones."""
if msg.photo:
return msg.photo[-1], "", "", "image"
if msg.video:
return msg.video, "", "video/mp4", "video"
if msg.voice:
return msg.voice, "voice.ogg", "audio/ogg", "audio"
if msg.audio:
return msg.audio, getattr(msg.audio, "file_name", "") or "", "", "audio"
if msg.document:
doc = msg.document
return doc, doc.file_name or "", (doc.mime_type or "").lower(), None
return None, "", "", None
@staticmethod
def _append_observed_note(existing: Optional[str], note: str) -> str:
if not note:
return existing or ""
if not existing:
return note
return f"{existing}\n\n{note}"
def _observe_unmentioned_group_message(
self,
message: Message,
msg_type: MessageType,
update_id: Optional[int] = None,
event: Optional[MessageEvent] = None,
) -> None:
"""Append skipped group chatter to the target session without dispatching."""
store = getattr(self, "_session_store", None)
if not store:
return
try:
event = event or self._build_message_event(message, msg_type, update_id=update_id)
shared_source = self._telegram_group_observe_shared_source(event.source)
session_entry = store.get_or_create_session(shared_source)
entry = {
"role": "user",
"content": self._telegram_group_observe_attributed_text(event),
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
"observed": True,
}
if event.message_id:
entry["message_id"] = str(event.message_id)
store.append_to_transcript(session_entry.session_id, entry)
adapter_name = getattr(self, "name", "telegram")
logger.info(
"[%s] Telegram group message observed (no bot trigger): chat=%s from=%s",
adapter_name,
getattr(getattr(message, "chat", None), "id", "unknown"),
event.source.user_id or "unknown",
)
except Exception as exc:
adapter_name = getattr(self, "name", "telegram")
logger.warning("[%s] Failed to observe Telegram group message: %s", adapter_name, exc)
def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool:
"""Apply Telegram group trigger rules.
DMs remain unrestricted. Group/supergroup messages are accepted when:
- the chat passes the ``allowed_chats`` whitelist (when set), or
``guest_mode`` is enabled and the bot is explicitly mentioned
- the chat is explicitly allowlisted in ``free_response_chats``
- ``require_mention`` is disabled
- the message replies to the bot
- the bot is @mentioned
- the text/caption matches a configured regex wake-word pattern
When ``allowed_chats`` is non-empty, it remains a hard gate except for
the narrow ``guest_mode`` bypass: group/supergroup messages that
explicitly @mention this bot. Replies and regex wake words do not bypass
``allowed_chats``. When ``require_mention`` is enabled, slash commands are not given
special treatment — they must pass the same mention/reply checks
as any other group message. Users can still trigger commands via
the Telegram bot menu (``/command@botname``) or by explicitly
mentioning the bot (``@botname /command``), both of which are
recognised as mentions by :meth:`_message_mentions_bot`.
"""
if not self._is_group_chat(message):
return True
thread_id = getattr(message, "message_thread_id", None)
allowed_topics = self._telegram_allowed_topics()
if allowed_topics:
topic_id = str(thread_id) if thread_id is not None else self._GENERAL_TOPIC_THREAD_ID
if topic_id not in allowed_topics:
return False
# Check ignored_threads first — applies to both groups and DM topics
if thread_id is not None:
try:
if int(thread_id) in self._telegram_ignored_threads():
return False
except (TypeError, ValueError):
logger.warning("[%s] Ignoring non-numeric Telegram message_thread_id: %r", self.name, thread_id)
if not self._is_group_chat(message):
# Root DM (non-topic): ignore if ignore_root_dm is configured
if thread_id is None and self.config.extra.get("ignore_root_dm", False):
chat_id = str(getattr(getattr(message, "chat", None), "id", ""))
if not is_command and chat_id in self._dm_topic_chat_ids:
return False
return True
chat_id_str = str(getattr(getattr(message, "chat", None), "id", ""))
if self._telegram_exclusive_bot_mentions() and self._explicit_bot_mentions_exclude_self(message):
return False
# Resolve guest-mode mention bypass once so _message_mentions_bot
# is not called redundantly in the normal flow below.
guest_mention = self._is_guest_mention(message)
# allowed_chats check (whitelist). When set, group messages from chats
# outside the whitelist are ignored unless guest_mode permits this
# exact message as an explicit direct mention. DMs are excluded above.
allowed = self._telegram_allowed_chats()
if allowed and chat_id_str not in allowed:
return guest_mention
if guest_mention:
return True
if chat_id_str in self._telegram_free_response_chats():
return True
if not self._telegram_require_mention():
return True
if self._is_reply_to_bot(message):
return True
# When guest_mode is True, _is_guest_mention already called
# _message_mentions_bot above — skip the redundant second call.
if not self._telegram_guest_mode() and self._message_mentions_bot(message):
return True
return self._message_matches_mention_patterns(message)
async def _ensure_forum_commands(self, message) -> None:
"""Lazy-register bot commands for forum supergroups.
Forum topics don't inherit AllGroupChats scope — Telegram resolves
via BotCommandScopeChat(chat_id). Register on first message so the
command menu works in topic views.
"""
async with self._forum_lock:
try:
chat = getattr(message, "chat", None)
if not chat or not getattr(chat, "is_forum", False):
return
chat_id = int(chat.id)
if chat_id in self._forum_command_registered:
return
from telegram import BotCommand, BotCommandScopeChat
from hermes_cli.commands import telegram_menu_commands
menu_commands, _ = telegram_menu_commands(max_commands=MAX_COMMANDS_PER_SCOPE)
bot_commands = [BotCommand(name, desc) for name, desc in menu_commands]
await self._bot.set_my_commands(bot_commands, scope=BotCommandScopeChat(chat_id=chat_id))
self._forum_command_registered.add(chat_id)
logger.info("[%s] Lazy-registered %d commands for forum chat %s", self.name, len(bot_commands), chat_id)
except Exception as e:
logger.warning("[%s] Forum command lazy-registration failed: %s", self.name, e)
def _effective_update_message(self, update: Update) -> Optional[Message]:
"""Return the message-like payload for normal messages and channel posts.
Telegram exposes channel broadcasts as ``update.channel_post`` rather
than ``update.message``. MessageHandler filters can still dispatch
those updates, so handlers must use ``effective_message`` to avoid
consuming channel posts without ever building a gateway event.
"""
return getattr(update, "effective_message", None) or getattr(update, "message", None)
async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle incoming text messages.
Telegram clients split long messages into multiple updates. Buffer
rapid successive text messages from the same user/chat and aggregate
them into a single MessageEvent before dispatching.
"""
msg = self._effective_update_message(update)
if not msg or not msg.text:
return
if not self._should_process_message(msg):
if self._should_observe_unmentioned_group_message(msg):
self._observe_unmentioned_group_message(msg, MessageType.TEXT, update_id=update.update_id)
return
await self._ensure_forum_commands(update.message)
event = self._build_message_event(msg, MessageType.TEXT, update_id=update.update_id)
event.text = self._clean_bot_trigger_text(event.text)
await self._cache_replied_media(msg, event)
event = self._apply_telegram_group_observe_attribution(event)
self._enqueue_text_event(event)
async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle incoming command messages."""
msg = self._effective_update_message(update)
if not msg or not msg.text:
return
if not self._should_process_message(msg, is_command=True):
return
await self._ensure_forum_commands(msg)
event = self._build_message_event(msg, MessageType.COMMAND, update_id=update.update_id)
event.text = self._clean_bot_trigger_text(event.text)
await self._cache_replied_media(msg, event)
event = self._apply_telegram_group_observe_attribution(event)
await self.handle_message(event)
async def _handle_location_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle incoming location/venue pin messages."""
msg = self._effective_update_message(update)
if not msg:
return
if not self._should_process_message(msg):
if self._should_observe_unmentioned_group_message(msg):
self._observe_unmentioned_group_message(msg, MessageType.LOCATION, update_id=update.update_id)
return
venue = getattr(msg, "venue", None)
location = getattr(venue, "location", None) if venue else getattr(msg, "location", None)
if not location:
return
lat = getattr(location, "latitude", None)
lon = getattr(location, "longitude", None)
if lat is None or lon is None:
return
# Build a text message with coordinates and context
parts = ["[The user shared a location pin.]"]
if venue:
title = getattr(venue, "title", None)
address = getattr(venue, "address", None)
if title:
parts.append(f"Venue: {title}")
if address:
parts.append(f"Address: {address}")
parts.append(f"latitude: {lat}")
parts.append(f"longitude: {lon}")
parts.append(f"Map: https://www.google.com/maps/search/?api=1&query={lat},{lon}")
parts.append("Ask what they'd like to find nearby (restaurants, cafes, etc.) and any preferences.")
event = self._build_message_event(msg, MessageType.LOCATION, update_id=update.update_id)
event.text = "\n".join(parts)
event = self._apply_telegram_group_observe_attribution(event)
await self.handle_message(event)
# ------------------------------------------------------------------
# Text message aggregation (handles Telegram client-side splits)
# ------------------------------------------------------------------
def _text_batch_key(self, event: MessageEvent) -> str:
"""Session-scoped key for text message batching.
Applies the installed topic-recovery hook first so DM-topic batches
coalesce on (and dispatch to) the recovered lane rather than the
raw inbound ``message_thread_id`` Telegram may have attached.
"""
from gateway.session import build_session_key
self._apply_topic_recovery(event)
return build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
)
def _enqueue_text_event(self, event: MessageEvent) -> None:
"""Buffer a text event and reset the flush timer.
When Telegram splits a long user message into multiple updates,
they arrive within a few hundred milliseconds. This method
concatenates them and waits for a short quiet period before
dispatching the combined message.
"""
key = self._text_batch_key(event)
existing = self._pending_text_batches.get(key)
chunk_len = len(event.text or "")
if existing is None:
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
self._pending_text_batches[key] = event
else:
# Append text from the follow-up chunk
if event.text:
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
# Merge any media that might be attached
if event.media_urls:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
# Cancel any pending flush and restart the timer
prior_task = self._pending_text_batch_tasks.get(key)
if prior_task and not prior_task.done():
prior_task.cancel()
self._pending_text_batch_tasks[key] = asyncio.create_task(
self._flush_text_batch(key)
)
async def _flush_text_batch(self, key: str) -> None:
"""Wait for the quiet period then dispatch the aggregated text.
Uses a longer delay when the latest chunk is near Telegram's 4096-char
split point, since a continuation chunk is almost certain.
"""
current_task = asyncio.current_task()
try:
# Adaptive delay tiers:
# - last chunk ≥ _SPLIT_THRESHOLD: a continuation is almost
# certain → wait the longer split delay.
# - total accumulated text ≤ _TEXT_BATCH_FAST_LEN (~320 cp):
# short message → cap delay at _TEXT_BATCH_FAST_DELAY_S
# so the agent sees the text near-instantly.
# - total ≤ _TEXT_BATCH_SHORT_LEN (~1024 cp):
# medium → cap at _TEXT_BATCH_SHORT_DELAY_S.
# - otherwise: use the configured cap.
# Tiers compose with operator overrides via the env-var-driven
# ``_text_batch_delay_seconds`` (e.g. an operator who sets the
# cap below 0.18s gets that lower number on every tier).
pending = self._pending_text_batches.get(key)
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
total_len = len(getattr(pending, "text", "") or "") if pending else 0
if last_len >= self._SPLIT_THRESHOLD:
delay = self._text_batch_split_delay_seconds
elif total_len <= self._TEXT_BATCH_FAST_LEN:
delay = min(self._text_batch_delay_seconds, self._TEXT_BATCH_FAST_DELAY_S)
elif total_len <= self._TEXT_BATCH_SHORT_LEN:
delay = min(self._text_batch_delay_seconds, self._TEXT_BATCH_SHORT_DELAY_S)
else:
delay = self._text_batch_delay_seconds
await asyncio.sleep(delay)
event = self._pending_text_batches.pop(key, None)
if not event:
return
logger.info(
"[Telegram] Flushing text batch %s (%d chars)",
key, len(event.text or ""),
)
await self.handle_message(event)
finally:
if self._pending_text_batch_tasks.get(key) is current_task:
self._pending_text_batch_tasks.pop(key, None)
# ------------------------------------------------------------------
# Photo batching
# ------------------------------------------------------------------
def _photo_batch_key(self, event: MessageEvent, msg: Message) -> str:
"""Return a batching key for Telegram photos/albums."""
from gateway.session import build_session_key
session_key = build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
)
media_group_id = getattr(msg, "media_group_id", None)
if media_group_id:
return f"{session_key}:album:{media_group_id}"
return f"{session_key}:photo-burst"
async def _flush_photo_batch(self, batch_key: str) -> None:
"""Send a buffered photo burst/album as a single MessageEvent."""
current_task = asyncio.current_task()
try:
await asyncio.sleep(self._media_batch_delay_seconds)
event = self._pending_photo_batches.pop(batch_key, None)
if not event:
return
logger.info("[Telegram] Flushing photo batch %s with %d image(s)", batch_key, len(event.media_urls))
await self.handle_message(event)
finally:
if self._pending_photo_batch_tasks.get(batch_key) is current_task:
self._pending_photo_batch_tasks.pop(batch_key, None)
def _enqueue_photo_event(self, batch_key: str, event: MessageEvent) -> None:
"""Merge photo events into a pending batch and schedule flush."""
existing = self._pending_photo_batches.get(batch_key)
if existing is None:
self._pending_photo_batches[batch_key] = event
else:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
if event.text:
existing.text = self._merge_caption(existing.text, event.text)
prior_task = self._pending_photo_batch_tasks.get(batch_key)
if prior_task and not prior_task.done():
prior_task.cancel()
self._pending_photo_batch_tasks[batch_key] = asyncio.create_task(self._flush_photo_batch(batch_key))
async def _handle_media_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle incoming media messages, downloading images to local cache."""
if not update.message:
return
if not self._should_process_message(update.message):
if self._should_observe_unmentioned_group_message(update.message):
_m = update.message
_observe_type = self._media_message_type(_m)
_event = self._build_message_event(_m, _observe_type, update_id=update.update_id)
if _m.caption:
_event.text = self._clean_bot_trigger_text(_m.caption)
await self._cache_observed_media(_m, _event)
self._observe_unmentioned_group_message(
_m, _event.message_type, update_id=update.update_id, event=_event
)
return
msg = update.message
msg_type = self._media_message_type(msg)
event = self._build_message_event(msg, msg_type, update_id=update.update_id)
# Add caption as text
if msg.caption:
event.text = self._clean_bot_trigger_text(msg.caption)
# Handle stickers: describe via vision tool with caching
if msg.sticker:
await self._handle_sticker(msg, event)
event = self._apply_telegram_group_observe_attribution(event)
await self.handle_message(event)
return
# Apply observe attribution after caption is set; sticker is handled above
# because _handle_sticker overwrites event.text with its vision description.
event = self._apply_telegram_group_observe_attribution(event)
# Download photo to local image cache so the vision tool can access it
# even after Telegram's ephemeral file URLs expire (~1 hour).
if msg.photo:
try:
# msg.photo is a list of PhotoSize sorted by size; take the largest
photo = msg.photo[-1]
file_obj = await photo.get_file()
# Download the image bytes directly into memory
image_bytes = await file_obj.download_as_bytearray()
# Determine extension from the file path if available
ext = ".jpg"
if file_obj.file_path:
for candidate in [".png", ".webp", ".gif", ".jpeg", ".jpg"]:
if file_obj.file_path.lower().endswith(candidate):
ext = candidate
break
# Save to local cache (for vision tool access)
cached_path = cache_image_from_bytes(bytes(image_bytes), ext=ext)
event.media_urls = [cached_path]
event.media_types = [f"image/{ext.lstrip('.')}" ]
logger.info("[Telegram] Cached user photo at %s", cached_path)
media_group_id = getattr(msg, "media_group_id", None)
if media_group_id:
await self._queue_media_group_event(str(media_group_id), event)
else:
batch_key = self._photo_batch_key(event, msg)
self._enqueue_photo_event(batch_key, event)
return
except Exception as e:
logger.warning("[Telegram] Failed to cache photo: %s", e, exc_info=True)
# 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")
event.media_urls = [cached_path]
event.media_types = ["audio/ogg"]
logger.info("[Telegram] Cached user voice at %s", cached_path)
except Exception as e:
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")
event.media_urls = [cached_path]
event.media_types = ["audio/mp3"]
logger.info("[Telegram] Cached user audio at %s", cached_path)
except Exception as e:
logger.warning("[Telegram] Failed to cache audio: %s", e, exc_info=True)
elif msg.video:
try:
file_obj = await msg.video.get_file()
video_bytes = await file_obj.download_as_bytearray()
ext = ".mp4"
if getattr(file_obj, "file_path", None):
for candidate in SUPPORTED_VIDEO_TYPES:
if file_obj.file_path.lower().endswith(candidate):
ext = candidate
break
cached_path = cache_video_from_bytes(bytes(video_bytes), ext=ext)
event.media_urls = [cached_path]
event.media_types = [SUPPORTED_VIDEO_TYPES.get(ext, "video/mp4")]
logger.info("[Telegram] Cached user video at %s", cached_path)
except Exception as e:
logger.warning("[Telegram] Failed to cache video: %s", e, exc_info=True)
# Download document files to cache for agent processing
elif msg.document:
doc = msg.document
try:
# Determine file extension
ext = ""
original_filename = doc.file_name or ""
if original_filename:
_, ext = os.path.splitext(original_filename)
ext = ext.lower()
# Normalize mime_type for robust comparisons (some clients send
# uppercase like "IMAGE/PNG").
doc_mime = (doc.mime_type or "").lower()
# If no extension from filename, reverse-lookup from MIME type
if not ext and doc_mime:
ext = _TELEGRAM_IMAGE_MIME_TO_EXT.get(doc_mime, "")
if not ext:
mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
ext = mime_to_ext.get(doc_mime, "")
# Check file size early so image documents cannot bypass the
# document size limit by taking the image path.
if not doc.file_size or doc.file_size > self._max_doc_bytes:
limit_mb = self._max_doc_bytes // (1024 * 1024)
event.text = (
"The document is too large or its size could not be verified. "
f"Maximum: {limit_mb} MB."
)
logger.info("[Telegram] Document too large: %s bytes", doc.file_size)
await self.handle_message(event)
return
# Telegram may deliver screenshots/photos as documents. If the
# payload is actually an image, route it through the image cache
# and batching path instead of rejecting it as a document.
if ext in _TELEGRAM_IMAGE_EXTENSIONS or doc_mime.startswith("image/"):
file_obj = await doc.get_file()
image_bytes = await file_obj.download_as_bytearray()
image_ext = ext if ext in _TELEGRAM_IMAGE_EXTENSIONS else _TELEGRAM_IMAGE_MIME_TO_EXT.get(doc_mime, ".jpg")
try:
cached_path = cache_image_from_bytes(bytes(image_bytes), ext=image_ext)
except ValueError as e:
logger.warning("[Telegram] Failed to cache image document: %s", e, exc_info=True)
event.text = (
f"Image document '{original_filename or doc_mime or ext or 'unknown'}' "
"could not be read as an image."
)
await self.handle_message(event)
return
event.message_type = MessageType.PHOTO
event.media_urls = [cached_path]
event.media_types = [doc_mime if doc_mime.startswith("image/") else _TELEGRAM_IMAGE_EXT_TO_MIME.get(image_ext, "image/jpeg")]
logger.info("[Telegram] Cached user image-document at %s", cached_path)
media_group_id = getattr(msg, "media_group_id", None)
if media_group_id:
await self._queue_media_group_event(str(media_group_id), event)
else:
batch_key = self._photo_batch_key(event, msg)
self._enqueue_photo_event(batch_key, event)
return
if not ext and doc.mime_type:
video_mime_to_ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()}
ext = video_mime_to_ext.get(doc.mime_type, "")
if not ext and doc.mime_type:
# SUPPORTED_IMAGE_DOCUMENT_TYPES has duplicate values (.jpg + .jpeg
# both map to image/jpeg); keep the first ext we encounter.
image_mime_to_ext: dict[str, str] = {}
for _ext, _mime in SUPPORTED_IMAGE_DOCUMENT_TYPES.items():
image_mime_to_ext.setdefault(_mime, _ext)
ext = image_mime_to_ext.get(doc.mime_type, "")
if ext in SUPPORTED_VIDEO_TYPES:
file_obj = await doc.get_file()
video_bytes = await file_obj.download_as_bytearray()
cached_path = cache_video_from_bytes(bytes(video_bytes), ext=ext)
event.media_urls = [cached_path]
event.media_types = [SUPPORTED_VIDEO_TYPES[ext]]
event.message_type = MessageType.VIDEO
logger.info("[Telegram] Cached user video document at %s", cached_path)
await self.handle_message(event)
return
# NOTE: image-document handling is performed earlier in this
# function (ext in _TELEGRAM_IMAGE_EXTENSIONS or image/* mime),
# which returns before reaching here. Any subsequent
# ext-in-SUPPORTED_IMAGE_DOCUMENT_TYPES branch would be dead
# code — the extension sets are identical.
# Check if supported
if ext not in SUPPORTED_DOCUMENT_TYPES:
supported_list = ", ".join(sorted(SUPPORTED_DOCUMENT_TYPES.keys()))
event.text = (
f"Unsupported document type '{ext or 'unknown'}'. "
f"Supported types: {supported_list}"
)
logger.info("[Telegram] Unsupported document type: %s", ext or "unknown")
await self.handle_message(event)
return
# Download and cache
file_obj = await doc.get_file()
doc_bytes = await file_obj.download_as_bytearray()
raw_bytes = bytes(doc_bytes)
cached_path = cache_document_from_bytes(raw_bytes, original_filename or f"document{ext}")
mime_type = SUPPORTED_DOCUMENT_TYPES[ext]
event.media_urls = [cached_path]
event.media_types = [mime_type]
logger.info("[Telegram] Cached user document at %s", cached_path)
# For text files, inject content into event.text (capped at 100 KB)
MAX_TEXT_INJECT_BYTES = 100 * 1024
if ext in {".md", ".txt"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES:
try:
text_content = raw_bytes.decode("utf-8")
display_name = original_filename or f"document{ext}"
display_name = re.sub(r'[^\w.\- ]', '_', display_name)
injection = f"[Content of {display_name}]:\n{text_content}"
if event.text:
event.text = f"{injection}\n\n{event.text}"
else:
event.text = injection
except UnicodeDecodeError:
logger.warning(
"[Telegram] Could not decode text file as UTF-8, skipping content injection",
exc_info=True,
)
except Exception as e:
logger.warning("[Telegram] Failed to cache document: %s", e, exc_info=True)
media_group_id = getattr(msg, "media_group_id", None)
if media_group_id:
await self._queue_media_group_event(str(media_group_id), event)
return
await self.handle_message(event)
async def _queue_media_group_event(self, media_group_id: str, event: MessageEvent) -> None:
"""Buffer Telegram media-group items so albums arrive as one logical event.
Telegram delivers albums as multiple updates with a shared media_group_id.
If we forward each item immediately, the gateway thinks the second image is a
new user message and interrupts the first. We debounce briefly and merge the
attachments into a single MessageEvent.
"""
existing = self._media_group_events.get(media_group_id)
if existing is None:
self._media_group_events[media_group_id] = event
else:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
if event.text:
existing.text = self._merge_caption(existing.text, event.text)
prior_task = self._media_group_tasks.get(media_group_id)
if prior_task:
prior_task.cancel()
self._media_group_tasks[media_group_id] = asyncio.create_task(
self._flush_media_group_event(media_group_id)
)
async def _flush_media_group_event(self, media_group_id: str) -> None:
try:
await asyncio.sleep(self.MEDIA_GROUP_WAIT_SECONDS)
event = self._media_group_events.pop(media_group_id, None)
if event is not None:
await self.handle_message(event)
except asyncio.CancelledError:
return
finally:
self._media_group_tasks.pop(media_group_id, None)
async def _handle_sticker(self, msg: Message, event: "MessageEvent") -> None:
"""
Describe a Telegram sticker via vision analysis, with caching.
For static stickers (WEBP), we download, analyze with vision, and cache
the description by file_unique_id. For animated/video stickers, we inject
a placeholder noting the emoji.
"""
from gateway.sticker_cache import (
get_cached_description,
cache_sticker_description,
build_sticker_injection,
build_animated_sticker_injection,
STICKER_VISION_PROMPT,
)
sticker = msg.sticker
emoji = sticker.emoji or ""
set_name = sticker.set_name or ""
# Animated and video stickers can't be analyzed as static images
if sticker.is_animated or sticker.is_video:
event.text = build_animated_sticker_injection(emoji)
return
# Check the cache first
cached = get_cached_description(sticker.file_unique_id)
if cached:
event.text = build_sticker_injection(
cached["description"], cached.get("emoji", emoji), cached.get("set_name", set_name)
)
logger.info("[Telegram] Sticker cache hit: %s", sticker.file_unique_id)
return
# Cache miss -- download and analyze
try:
file_obj = await sticker.get_file()
image_bytes = await file_obj.download_as_bytearray()
cached_path = cache_image_from_bytes(bytes(image_bytes), ext=".webp")
logger.info("[Telegram] Analyzing sticker at %s", cached_path)
from tools.vision_tools import vision_analyze_tool
result_json = await vision_analyze_tool(
image_url=cached_path,
user_prompt=STICKER_VISION_PROMPT,
)
result = json.loads(result_json)
if result.get("success"):
description = result.get("analysis", "a sticker")
cache_sticker_description(sticker.file_unique_id, description, emoji, set_name)
event.text = build_sticker_injection(description, emoji, set_name)
else:
# Vision failed -- use emoji as fallback
event.text = build_sticker_injection(
f"a sticker with emoji {emoji}" if emoji else "a sticker",
emoji, set_name,
)
except Exception as e:
logger.warning("[Telegram] Sticker analysis error: %s", e, exc_info=True)
event.text = build_sticker_injection(
f"a sticker with emoji {emoji}" if emoji else "a sticker",
emoji, set_name,
)
def _reload_dm_topics_from_config(self) -> None:
"""Re-read dm_topics from config.yaml and load any new thread_ids into cache.
This allows topics created externally (e.g. by the agent via API) to be
recognized without a gateway restart.
"""
try:
from hermes_constants import get_hermes_home
config_path = get_hermes_home() / "config.yaml"
if not config_path.exists():
return
import yaml as _yaml
with open(config_path, "r", encoding="utf-8") as f:
config = _yaml.safe_load(f) or {}
dm_topics = (
config.get("platforms", {})
.get("telegram", {})
.get("extra", {})
.get("dm_topics", [])
)
if not dm_topics:
# Clear both config and precomputed set when all topics are removed
self._dm_topics_config = []
self._dm_topic_chat_ids = set()
return
# Update in-memory config and cache any new thread_ids
self._dm_topics_config = dm_topics
# Rebuild the chat_id set for O(1) root-DM ignore lookup
self._dm_topic_chat_ids = {
str(chat_entry["chat_id"]) for chat_entry in dm_topics if "chat_id" in chat_entry
}
for chat_entry in dm_topics:
cid = chat_entry.get("chat_id")
if not cid:
continue
for t in chat_entry.get("topics", []):
tid = t.get("thread_id")
name = t.get("name")
if tid and name:
cache_key = f"{cid}:{name}"
if cache_key not in self._dm_topics:
self._dm_topics[cache_key] = int(tid)
logger.info(
"[%s] Hot-loaded DM topic from config: %s -> thread_id=%s",
self.name, cache_key, tid,
)
except Exception as e:
logger.debug("[%s] Failed to reload dm_topics from config: %s", self.name, e)
def _get_dm_topic_info(self, chat_id: str, thread_id: Optional[str]) -> Optional[Dict[str, Any]]:
"""Look up DM topic config by chat_id and thread_id.
Returns the topic config dict (name, skill, etc.) if this thread_id
matches a known DM topic, or None.
"""
if not thread_id:
return None
thread_id_int = int(thread_id)
# Check cached topics first (created by us or loaded at startup)
for key, cached_tid in self._dm_topics.items():
if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"):
topic_name = key.split(":", 1)[1]
# Find the full config for this topic
for chat_entry in self._dm_topics_config:
if str(chat_entry.get("chat_id")) == chat_id:
for t in chat_entry.get("topics", []):
if t.get("name") == topic_name:
return t
return {"name": topic_name}
# Not in cache — hot-reload config in case topics were added externally
self._reload_dm_topics_from_config()
# Check cache again after reload
for key, cached_tid in self._dm_topics.items():
if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"):
topic_name = key.split(":", 1)[1]
for chat_entry in self._dm_topics_config:
if str(chat_entry.get("chat_id")) == chat_id:
for t in chat_entry.get("topics", []):
if t.get("name") == topic_name:
return t
return {"name": topic_name}
return None
def _cache_dm_topic_from_message(self, chat_id: str, thread_id: str, topic_name: str) -> None:
"""Cache a thread_id -> topic_name mapping discovered from an incoming message."""
cache_key = f"{chat_id}:{topic_name}"
if cache_key not in self._dm_topics:
self._dm_topics[cache_key] = int(thread_id)
logger.info(
"[%s] Cached DM topic from message: %s -> thread_id=%s",
self.name, cache_key, thread_id,
)
def _build_message_event(
self,
message: Message,
msg_type: MessageType,
update_id: Optional[int] = None,
) -> MessageEvent:
"""Build a MessageEvent from a Telegram message.
``update_id`` is the ``Update.update_id`` from PTB; passing it through
lets ``/restart`` record the triggering offset so the new gateway
process can advance past it (prevents ``/restart`` being re-delivered
when PTB's graceful-shutdown ACK fails).
"""
chat = message.chat
user = message.from_user
# Determine chat type. Normalize through ``str`` so tests/mocks and
# python-telegram-bot enum values both work (``ChatType.CHANNEL`` is
# string-like, but mocks often provide plain strings).
telegram_chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower()
chat_type = "dm"
if telegram_chat_type in {"group", "supergroup"}:
chat_type = "group"
elif telegram_chat_type == "channel":
chat_type = "channel"
# Resolve Telegram topic name and skill binding.
# Only preserve message_thread_id when Telegram marks the message as
# a real topic/forum message. Telegram can also populate
# message_thread_id for ordinary reply UI anchors; treating those as
# durable session threads fragments workflows such as CAPTCHA/login
# handoffs where the user later replies "done" in the same group.
# Private chats have the same pitfall: only real DM topic messages
# (is_topic_message=True) should keep the thread id, otherwise sends
# can hit Telegram's 'Message thread not found' error (#3206).
thread_id_raw = message.message_thread_id
is_topic_message = bool(getattr(message, "is_topic_message", False))
is_forum_group = getattr(chat, "is_forum", False) is True
thread_id_str = None
if thread_id_raw is not None:
if chat_type == "group" and (is_topic_message or is_forum_group):
thread_id_str = str(thread_id_raw)
elif chat_type == "dm" and is_topic_message:
thread_id_str = str(thread_id_raw)
# For forum groups without an explicit topic, default to the
# General-topic id so the gateway routes back to the General topic
# rather than dropping into the bot's main channel (#22423).
if chat_type == "group" and thread_id_str is None and is_forum_group:
thread_id_str = self._GENERAL_TOPIC_THREAD_ID
chat_topic = None
topic_skill = None
if chat_type == "dm" and thread_id_str:
topic_info = self._get_dm_topic_info(str(chat.id), thread_id_str)
if topic_info:
chat_topic = topic_info.get("name")
topic_skill = topic_info.get("skill")
# Also check forum_topic_created service message for topic discovery
if hasattr(message, "forum_topic_created") and message.forum_topic_created:
created_name = message.forum_topic_created.name
if created_name:
self._cache_dm_topic_from_message(str(chat.id), thread_id_str, created_name)
if not chat_topic:
chat_topic = created_name
elif chat_type == "group" and thread_id_str:
# Group/supergroup forum topic skill binding via config.extra['group_topics']
group_topics_config: list = self.config.extra.get("group_topics", [])
for chat_entry in group_topics_config:
if str(chat_entry.get("chat_id", "")) == str(chat.id):
for topic in chat_entry.get("topics", []):
tid = topic.get("thread_id")
if tid is not None and str(tid) == thread_id_str:
chat_topic = topic.get("name")
topic_skill = topic.get("skill")
break
break
# Build source
source = self.build_source(
chat_id=str(chat.id),
chat_name=chat.title or (chat.full_name if hasattr(chat, "full_name") else None),
chat_type=chat_type,
user_id=(
str(user.id)
if user
else (str(chat.id) if chat_type in {"dm", "channel"} else None)
),
user_name=(
user.full_name
if user
else (
chat.full_name
if hasattr(chat, "full_name") and chat_type == "dm"
else (chat.title if chat_type == "channel" else None)
)
),
thread_id=thread_id_str,
chat_topic=chat_topic,
message_id=str(message.message_id),
)
# Extract reply context if this message is a reply.
# Prefer Telegram's native partial quote (message.quote, TextQuote)
# so a user replying to a single selected substring of a prior
# multi-section message doesn't get the whole replied-to message
# injected into the agent's context — which can cause the agent
# to act on unrelated actionable-looking text the user didn't
# quote (#22619). Fall back to the full replied-to message text
# / caption when no native quote is present.
reply_to_id = None
reply_to_text = None
if message.reply_to_message:
reply_to_id = str(message.reply_to_message.message_id)
quote = getattr(message, "quote", None)
quote_text = getattr(quote, "text", None) if quote is not None else None
if quote_text:
reply_to_text = quote_text
else:
reply_to_text = (
message.reply_to_message.text
or message.reply_to_message.caption
or None
)
# Per-channel/topic ephemeral prompt
from gateway.platforms.base import resolve_channel_prompt
_chat_id_str = str(chat.id)
_channel_prompt = resolve_channel_prompt(
self.config.extra,
thread_id_str or _chat_id_str,
_chat_id_str if thread_id_str else None,
)
return MessageEvent(
text=message.text or "",
message_type=msg_type,
source=source,
raw_message=message,
message_id=str(message.message_id),
platform_update_id=update_id,
reply_to_message_id=reply_to_id,
reply_to_text=reply_to_text,
auto_skill=topic_skill,
channel_prompt=_channel_prompt,
timestamp=message.date,
)
# ── Message reactions (processing lifecycle) ──────────────────────────
def _reactions_enabled(self) -> bool:
"""Check if message reactions are enabled via config/env."""
return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in {"false", "0", "no"}
async def _set_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
"""Set a single emoji reaction on a Telegram message."""
if not self._bot:
return False
try:
await self._bot.set_message_reaction(
chat_id=int(chat_id),
message_id=int(message_id),
reaction=emoji,
)
return True
except Exception as e:
logger.debug("[%s] set_message_reaction failed (%s): %s", self.name, emoji, e)
return False
async def _clear_reactions(self, chat_id: str, message_id: str) -> bool:
"""Clear all reactions from a Telegram message.
Calling ``set_message_reaction`` with ``reaction=None`` (or an empty
sequence) is the documented Bot API way to remove all bot-set
reactions on a message — equivalent to Bot API 10.0's
``deleteMessageReaction`` but supported in PTB 22.6 already.
"""
if not self._bot:
return False
try:
await self._bot.set_message_reaction(
chat_id=int(chat_id),
message_id=int(message_id),
reaction=None,
)
return True
except Exception as e:
logger.debug("[%s] clear reactions failed: %s", self.name, e)
return False
async def on_processing_start(self, event: MessageEvent) -> None:
"""Add an in-progress reaction when message processing begins."""
if not self._reactions_enabled():
return
chat_id = getattr(event.source, "chat_id", None)
message_id = getattr(event, "message_id", None)
if chat_id and message_id:
await self._set_reaction(chat_id, message_id, "\U0001f440")
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
"""Swap the in-progress reaction for a final success/failure reaction.
Unlike Discord (additive reactions), Telegram's set_message_reaction
replaces all existing reactions in one call — no remove step needed.
On CANCELLED outcomes (e.g. the user runs ``/stop``, or a session is
interrupted mid-flight), we explicitly clear the 👀 in-progress
reaction so it doesn't linger on the user's message indefinitely.
Without this clear, the only way to remove the 👀 was to wait for
another agent run to swap it to 👍/👎 — which never happens if the
cancellation was the last activity in the chat.
"""
if not self._reactions_enabled():
return
chat_id = getattr(event.source, "chat_id", None)
message_id = getattr(event, "message_id", None)
if not (chat_id and message_id):
return
if outcome == ProcessingOutcome.CANCELLED:
await self._clear_reactions(chat_id, message_id)
else:
await self._set_reaction(
chat_id,
message_id,
"\U0001f44d" if outcome == ProcessingOutcome.SUCCESS else "\U0001f44e",
)