Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 40 additions & 32 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ class _MockContextTypes:
MAX_COMMANDS_PER_SCOPE = 30


def _coerce_chat_id(value: Any) -> Any:
"""Return numeric chat ids as ints while preserving @username targets."""
try:
return int(value)
except (TypeError, ValueError):
return value


def check_telegram_requirements() -> bool:
"""Check if Telegram dependencies are available.

Expand Down Expand Up @@ -1943,7 +1951,7 @@ async def send(
# Try Markdown first, fall back to plain text if it fails
try:
msg = await self._bot.send_message(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
text=chunk,
parse_mode=ParseMode.MARKDOWN_V2,
reply_to_message_id=reply_to_id,
Expand All @@ -1957,7 +1965,7 @@ async def send(
logger.warning("[%s] MarkdownV2 parse failed, falling back to plain text: %s", self.name, md_error)
plain_chunk = _strip_mdv2(chunk)
msg = await self._bot.send_message(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
text=plain_chunk,
parse_mode=None,
reply_to_message_id=reply_to_id,
Expand Down Expand Up @@ -2183,7 +2191,7 @@ async def edit_message(
try:
if not finalize:
await self._bot.edit_message_text(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
message_id=int(message_id),
text=content,
)
Expand All @@ -2192,7 +2200,7 @@ async def edit_message(
formatted = self.format_message(content)
try:
await self._bot.edit_message_text(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
message_id=int(message_id),
text=formatted,
parse_mode=ParseMode.MARKDOWN_V2,
Expand All @@ -2203,7 +2211,7 @@ async def edit_message(
return SendResult(success=True, message_id=message_id)
# Fallback: retry without markdown formatting
await self._bot.edit_message_text(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
message_id=int(message_id),
text=content,
)
Expand Down Expand Up @@ -2239,7 +2247,7 @@ async def edit_message(
await asyncio.sleep(wait)
try:
await self._bot.edit_message_text(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
message_id=int(message_id),
text=content,
)
Expand Down Expand Up @@ -2326,21 +2334,21 @@ async def _edit_overflow_split(
formatted = self.format_message(first_chunk)
try:
await self._bot.edit_message_text(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(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():
await self._bot.edit_message_text(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
message_id=int(message_id),
text=first_chunk,
)
else:
await self._bot.edit_message_text(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
message_id=int(message_id),
text=first_chunk,
)
Expand Down Expand Up @@ -2379,7 +2387,7 @@ async def _edit_overflow_split(
try:
text = self.format_message(chunk) if use_markdown else chunk
sent_msg = await self._bot.send_message(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
text=text,
parse_mode=ParseMode.MARKDOWN_V2 if use_markdown else None,
reply_to_message_id=reply_to_id,
Expand All @@ -2402,7 +2410,7 @@ async def _edit_overflow_split(
)
try:
sent_msg = await self._bot.send_message(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
text=chunk,
**retry_thread_kwargs,
**self._link_preview_kwargs(),
Expand Down Expand Up @@ -2465,7 +2473,7 @@ async def delete_message(self, chat_id: str, message_id: str) -> bool:
return False
try:
await self._bot.delete_message(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
message_id=int(message_id),
)
return True
Expand Down Expand Up @@ -2534,7 +2542,7 @@ async def send_draft(
# kills draft streaming for the whole response.
for use_markdown in (True, False):
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"chat_id": _coerce_chat_id(chat_id),
"draft_id": int(draft_id),
"text": self.format_message(text) if use_markdown else text,
}
Expand Down Expand Up @@ -2627,7 +2635,7 @@ async def send_update_prompt(
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),
chat_id=_coerce_chat_id(chat_id),
text=text,
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
Expand Down Expand Up @@ -2690,7 +2698,7 @@ async def send_exec_approval(
])

kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"chat_id": _coerce_chat_id(chat_id),
"text": text,
"parse_mode": ParseMode.HTML,
"reply_markup": keyboard,
Expand Down Expand Up @@ -2741,7 +2749,7 @@ async def send_slash_confirm(

thread_id = self._metadata_thread_id(metadata)
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"chat_id": _coerce_chat_id(chat_id),
"text": preview,
"parse_mode": ParseMode.MARKDOWN_V2,
"reply_markup": keyboard,
Expand Down Expand Up @@ -2805,7 +2813,7 @@ async def send_clarify(
text += f"\n\n{option_lines}"

kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"chat_id": _coerce_chat_id(chat_id),
"text": text,
"parse_mode": ParseMode.HTML,
**self._link_preview_kwargs(),
Expand Down Expand Up @@ -2889,7 +2897,7 @@ def get_label(slug):
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),
chat_id=_coerce_chat_id(chat_id),
text=text,
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
Expand Down Expand Up @@ -3739,7 +3747,7 @@ async def send_voice(
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_voice,
{
"chat_id": int(chat_id),
"chat_id": _coerce_chat_id(chat_id),
"voice": audio_file,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
Expand All @@ -3765,7 +3773,7 @@ async def send_voice(
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_audio,
{
"chat_id": int(chat_id),
"chat_id": _coerce_chat_id(chat_id),
"audio": audio_file,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
Expand Down Expand Up @@ -3904,7 +3912,7 @@ def _reset_opened_files() -> None:
await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_media_group,
{
"chat_id": int(chat_id),
"chat_id": _coerce_chat_id(chat_id),
"media": media,
"reply_to_message_id": reply_to_id,
**thread_kwargs,
Expand Down Expand Up @@ -3962,7 +3970,7 @@ async def send_image_file(
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_photo,
{
"chat_id": int(chat_id),
"chat_id": _coerce_chat_id(chat_id),
"photo": image_file,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
Expand Down Expand Up @@ -4058,7 +4066,7 @@ async def send_document(
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_document,
{
"chat_id": int(chat_id),
"chat_id": _coerce_chat_id(chat_id),
"document": f,
"filename": display_name,
"caption": caption[:1024] if caption else None,
Expand Down Expand Up @@ -4106,7 +4114,7 @@ async def send_video(
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_video,
{
"chat_id": int(chat_id),
"chat_id": _coerce_chat_id(chat_id),
"video": f,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
Expand Down Expand Up @@ -4158,7 +4166,7 @@ async def send_image(
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_photo,
{
"chat_id": int(chat_id),
"chat_id": _coerce_chat_id(chat_id),
"photo": image_url,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
Expand Down Expand Up @@ -4195,7 +4203,7 @@ async def send_image(
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_photo,
{
"chat_id": int(chat_id),
"chat_id": _coerce_chat_id(chat_id),
"photo": image_data,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
Expand Down Expand Up @@ -4242,7 +4250,7 @@ async def send_animation(
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_animation,
{
"chat_id": int(chat_id),
"chat_id": _coerce_chat_id(chat_id),
"animation": animation_url,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
Expand Down Expand Up @@ -4274,7 +4282,7 @@ async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = N
_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),
chat_id=_coerce_chat_id(chat_id),
action="typing",
message_thread_id=message_thread_id,
)
Expand All @@ -4285,7 +4293,7 @@ async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = N
if _is_dm_topic and message_thread_id is not None:
try:
await self._bot.send_chat_action(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
action="typing",
)
return
Expand All @@ -4305,7 +4313,7 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": "Unknown", "type": "dm"}

try:
chat = await self._bot.get_chat(int(chat_id))
chat = await self._bot.get_chat(_coerce_chat_id(chat_id))

chat_type = "dm"
if chat.type == ChatType.GROUP:
Expand Down Expand Up @@ -6013,7 +6021,7 @@ async def _set_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool
return False
try:
await self._bot.set_message_reaction(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
message_id=int(message_id),
reaction=emoji,
)
Expand All @@ -6034,7 +6042,7 @@ async def _clear_reactions(self, chat_id: str, message_id: str) -> bool:
return False
try:
await self._bot.set_message_reaction(
chat_id=int(chat_id),
chat_id=_coerce_chat_id(chat_id),
message_id=int(message_id),
reaction=None,
)
Expand Down
77 changes: 77 additions & 0 deletions tests/gateway/test_telegram_chat_id_coercion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Regression tests for Telegram outbound chat_id handling."""

from __future__ import annotations

import sys
import types
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest

from gateway.config import PlatformConfig


def _install_fake_telegram(monkeypatch):
"""Stub python-telegram-bot so the adapter import is deterministic."""
fake_telegram = types.ModuleType("telegram")
fake_telegram.Update = SimpleNamespace(ALL_TYPES=())
fake_telegram.Bot = object
fake_telegram.Message = object
fake_telegram.InlineKeyboardButton = object
fake_telegram.InlineKeyboardMarkup = object

fake_error = types.ModuleType("telegram.error")
fake_error.NetworkError = type("NetworkError", (Exception,), {})
fake_error.BadRequest = type("BadRequest", (fake_error.NetworkError,), {})
fake_error.TimedOut = type("TimedOut", (fake_error.NetworkError,), {})
fake_telegram.error = fake_error

fake_constants = types.ModuleType("telegram.constants")
fake_constants.ParseMode = SimpleNamespace(
MARKDOWN="Markdown",
MARKDOWN_V2="MarkdownV2",
HTML="HTML",
)
fake_constants.ChatType = SimpleNamespace(
GROUP="group",
SUPERGROUP="supergroup",
CHANNEL="channel",
PRIVATE="private",
)
fake_telegram.constants = fake_constants

fake_ext = types.ModuleType("telegram.ext")
fake_ext.Application = object
fake_ext.CommandHandler = object
fake_ext.CallbackQueryHandler = object
fake_ext.MessageHandler = object
fake_ext.ContextTypes = SimpleNamespace(DEFAULT_TYPE=object)
fake_ext.filters = object

fake_request = types.ModuleType("telegram.request")
fake_request.HTTPXRequest = object

monkeypatch.setitem(sys.modules, "telegram", fake_telegram)
monkeypatch.setitem(sys.modules, "telegram.error", fake_error)
monkeypatch.setitem(sys.modules, "telegram.constants", fake_constants)
monkeypatch.setitem(sys.modules, "telegram.ext", fake_ext)
monkeypatch.setitem(sys.modules, "telegram.request", fake_request)


@pytest.mark.asyncio
async def test_send_allows_public_channel_username_chat_id(monkeypatch):
_install_fake_telegram(monkeypatch)
from gateway.platforms.telegram import TelegramAdapter

adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token"))
adapter._bot = SimpleNamespace(
send_message=AsyncMock(return_value=SimpleNamespace(message_id=123)),
)

result = await adapter.send("@bhuk4", "hello")

assert result.success is True
adapter._bot.send_message.assert_awaited_once()
assert adapter._bot.send_message.await_args.kwargs["chat_id"] == "@bhuk4"