mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 08:52:45 +00:00
386 lines
14 KiB
Python
386 lines
14 KiB
Python
"""
|
|
Telegram Polling Platform (F29 Remediation).
|
|
Long-polling implementation for Telegram Bot API.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
import time
|
|
from typing import Optional
|
|
|
|
from services.connector_replay_lifecycle import ConnectorReplayLifecycle
|
|
|
|
from ..config import ConnectorConfig
|
|
from ..contract import CommandRequest, CommandResponse
|
|
from ..reply_visibility import decide_reply_visibility
|
|
from ..router import CommandRouter
|
|
from ..state import ConnectorState
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_THREAD_ID_RE = re.compile(r"^\d{1,10}$")
|
|
|
|
|
|
def _import_aiohttp():
|
|
try:
|
|
import aiohttp # type: ignore
|
|
except ModuleNotFoundError:
|
|
return None
|
|
return aiohttp
|
|
|
|
|
|
def _normalize_message_thread_id(value) -> Optional[int]:
|
|
if value is None or isinstance(value, bool):
|
|
return None
|
|
text = str(value).strip()
|
|
if not _THREAD_ID_RE.fullmatch(text):
|
|
return None
|
|
try:
|
|
thread_id = int(text)
|
|
except ValueError:
|
|
return None
|
|
if thread_id <= 0:
|
|
return None
|
|
return thread_id
|
|
|
|
|
|
def _telegram_channel_kind(chat_id) -> str:
|
|
text = str(chat_id or "").strip()
|
|
if text.startswith("-100"):
|
|
return "supergroup"
|
|
if text.startswith("-"):
|
|
return "group"
|
|
return "dm"
|
|
|
|
|
|
class TelegramPolling:
|
|
def __init__(self, config: ConnectorConfig, router: CommandRouter):
|
|
self.config = config
|
|
self.router = router
|
|
self.state_store = ConnectorState(path=self.config.state_path)
|
|
self.token = config.telegram_bot_token
|
|
self.base_url = f"https://api.telegram.org/bot{self.token}"
|
|
|
|
# Remediation: Load offset from persistent state
|
|
self.offset = self.state_store.get_offset("telegram")
|
|
self.session = None
|
|
self._update_lifecycle = ConnectorReplayLifecycle(
|
|
ttl_sec=300,
|
|
max_entries=5000,
|
|
)
|
|
|
|
async def start(self):
|
|
aiohttp = _import_aiohttp()
|
|
if aiohttp is None:
|
|
logger.warning("aiohttp not installed. Skipping Telegram adapter.")
|
|
return
|
|
|
|
if not self.token:
|
|
logger.warning("Telegram token not configured. Skipping.")
|
|
return
|
|
|
|
logger.info(f"Starting Telegram Polling (offset={self.offset})...")
|
|
async with aiohttp.ClientSession() as self.session:
|
|
while True:
|
|
try:
|
|
await self._poll_once()
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Telegram poll error: {e}")
|
|
await asyncio.sleep(5)
|
|
|
|
async def _poll_once(self):
|
|
url = f"{self.base_url}/getUpdates"
|
|
params = {"offset": self.offset, "timeout": 30}
|
|
|
|
async with self.session.get(url, params=params) as resp:
|
|
if resp.status != 200:
|
|
# IMPORTANT (debuggability): Telegram frequently returns actionable details in the body
|
|
# for non-200 responses (e.g. 409 conflict: "terminated by other getUpdates request",
|
|
# or "webhook is active"). Log the response text in debug mode to speed up diagnosis.
|
|
try:
|
|
body = await resp.text()
|
|
except Exception:
|
|
body = ""
|
|
if body:
|
|
logger.error(f"Telegram API Error {resp.status}: {body}")
|
|
else:
|
|
logger.error(f"Telegram API Error {resp.status}")
|
|
await asyncio.sleep(5)
|
|
return
|
|
|
|
data = await resp.json()
|
|
if not data.get("ok"):
|
|
# Telegram sometimes returns `ok=false` with a useful description even on 200.
|
|
# Keep logs concise, but include enough context to fix config issues quickly.
|
|
desc = (
|
|
data.get("description") or data.get("error_code") or "unknown_error"
|
|
)
|
|
logger.warning(f"Telegram API returned ok=false: {desc}")
|
|
return
|
|
|
|
updates = data.get("result", [])
|
|
if self.config.debug and not updates:
|
|
logger.debug("Telegram poll OK (no updates). offset=%s", self.offset)
|
|
for update in updates:
|
|
update_id = update["update_id"]
|
|
lifecycle_key = f"telegram:update:{update_id}"
|
|
claim = self._update_lifecycle.claim(
|
|
lifecycle_key,
|
|
metadata={"platform": "telegram"},
|
|
)
|
|
if not claim.accepted:
|
|
logger.debug(
|
|
"Telegram duplicate update_id=%s code=%s state=%s",
|
|
update_id,
|
|
claim.code,
|
|
claim.record.state,
|
|
)
|
|
if claim.code == "duplicate_after_success":
|
|
self._commit_offset(update_id + 1)
|
|
continue
|
|
|
|
processed = await self._process_update(update)
|
|
if processed:
|
|
self._update_lifecycle.commit_success(
|
|
lifecycle_key, reason="processed"
|
|
)
|
|
self._commit_offset(update_id + 1)
|
|
else:
|
|
# IMPORTANT: keep failed-before-delivery updates retryable.
|
|
# Advancing the Telegram offset here would drop the update.
|
|
self._update_lifecycle.release_retryable(
|
|
lifecycle_key, reason="telegram_update_failed_before_commit"
|
|
)
|
|
|
|
def _commit_offset(self, next_offset: int) -> None:
|
|
if next_offset > self.offset:
|
|
self.offset = next_offset
|
|
self.state_store.set_offset("telegram", self.offset)
|
|
|
|
async def _process_update(self, update: dict) -> bool:
|
|
# Telegram update shapes vary by chat type and sender mode.
|
|
# - Normal groups/DMs: `message`
|
|
# - Edited messages: `edited_message`
|
|
# - Channels: `channel_post` / `edited_channel_post`
|
|
#
|
|
# IMPORTANT (recurring support issue):
|
|
# If users say "DM works but group/channel does nothing" AND connector logs show no
|
|
# `DEBUG raw message`, it's often because updates are arriving under `channel_post`
|
|
# (or `sender_chat` anonymous posts) which older code ignored.
|
|
message = (
|
|
update.get("message")
|
|
or update.get("edited_message")
|
|
or update.get("channel_post")
|
|
or update.get("edited_channel_post")
|
|
)
|
|
if not message or "text" not in message:
|
|
return True
|
|
|
|
chat_id = message["chat"]["id"]
|
|
# `from` may be missing for channel posts; `sender_chat` is used for anonymous admins.
|
|
from_obj = message.get("from") or {}
|
|
sender_chat = message.get("sender_chat") or {}
|
|
user_id = from_obj.get("id")
|
|
username = from_obj.get("username") or sender_chat.get("username") or "unknown"
|
|
text = message["text"]
|
|
message_thread_id = _normalize_message_thread_id(
|
|
message.get("message_thread_id")
|
|
)
|
|
|
|
# Security Check
|
|
is_allowed = False
|
|
if isinstance(user_id, int) and user_id in self.config.telegram_allowed_users:
|
|
is_allowed = True
|
|
if chat_id in self.config.telegram_allowed_chats:
|
|
is_allowed = True
|
|
|
|
if not is_allowed and self.config.debug:
|
|
logger.debug(
|
|
f"Untrusted Telegram message user={user_id} chat={chat_id} (will require approval)"
|
|
)
|
|
|
|
# Build Request
|
|
req = CommandRequest(
|
|
platform="telegram",
|
|
# If `user_id` is missing (channel posts), fall back to chat_id so allowlisting by
|
|
# `OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_CHATS` still works deterministically.
|
|
sender_id=str(user_id) if user_id is not None else str(chat_id),
|
|
channel_id=str(chat_id),
|
|
username=username,
|
|
message_id=str(message["message_id"]),
|
|
text=text,
|
|
timestamp=time.time(),
|
|
thread_id=str(message_thread_id or ""),
|
|
)
|
|
|
|
try:
|
|
resp = await self.router.handle(req)
|
|
return await self._send_response(
|
|
chat_id,
|
|
resp,
|
|
delivery_context=(
|
|
{"thread_id": req.thread_id} if req.thread_id else None
|
|
),
|
|
)
|
|
except Exception as e:
|
|
logger.exception(f"Error handling command: {e}")
|
|
return await self._send_response(
|
|
chat_id,
|
|
CommandResponse(text="[Error] Internal processing error."),
|
|
delivery_context=(
|
|
{"thread_id": req.thread_id} if req.thread_id else None
|
|
),
|
|
)
|
|
|
|
def _thread_id_from_context(
|
|
self, delivery_context: Optional[dict]
|
|
) -> Optional[int]:
|
|
context = delivery_context or {}
|
|
return _normalize_message_thread_id(context.get("thread_id"))
|
|
|
|
async def _send_thread_diagnostic(self, chat_id, raw_thread_id) -> None:
|
|
preview = str(raw_thread_id or "")[:32]
|
|
logger.warning("Invalid Telegram message_thread_id ignored: %r", preview)
|
|
if not self.session:
|
|
return
|
|
url = f"{self.base_url}/sendMessage"
|
|
payload = {
|
|
"chat_id": chat_id,
|
|
"text": "[OpenClaw] Invalid Telegram thread/topic id; delivery used the parent chat.",
|
|
}
|
|
try:
|
|
async with self.session.post(url, json=payload) as r:
|
|
if r.status != 200:
|
|
logger.error(
|
|
f"Failed to send Telegram thread diagnostic: {r.status} {await r.text()}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Telegram thread diagnostic exception: {e}")
|
|
|
|
async def _send_response(
|
|
self,
|
|
chat_id: int,
|
|
resp: CommandResponse,
|
|
delivery_context: Optional[dict] = None,
|
|
) -> bool:
|
|
decision = decide_reply_visibility(
|
|
delivery_context=dict(delivery_context or {}),
|
|
platform="telegram",
|
|
channel_kind=_telegram_channel_kind(chat_id),
|
|
text=getattr(resp, "text", ""),
|
|
has_buttons=bool(getattr(resp, "buttons", None)),
|
|
has_files=bool(getattr(resp, "files", None)),
|
|
)
|
|
if decision.suppressed:
|
|
logger.info(
|
|
"Suppressed Telegram reply chat=%s reason=%s",
|
|
chat_id,
|
|
decision.reason,
|
|
)
|
|
return True
|
|
url = f"{self.base_url}/sendMessage"
|
|
payload = {
|
|
"chat_id": chat_id,
|
|
# Remediation: Plain text only, no parse_mode
|
|
"text": resp.text,
|
|
}
|
|
thread_id = self._thread_id_from_context(delivery_context)
|
|
if thread_id is not None:
|
|
payload["message_thread_id"] = thread_id
|
|
elif delivery_context and delivery_context.get("thread_id"):
|
|
await self._send_thread_diagnostic(
|
|
chat_id, delivery_context.get("thread_id")
|
|
)
|
|
try:
|
|
async with self.session.post(url, json=payload) as r:
|
|
if r.status != 200:
|
|
logger.error(
|
|
f"Failed to send Telegram response: {r.status} {await r.text()}"
|
|
)
|
|
return False
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Telegram send exception: {e}")
|
|
return False
|
|
|
|
async def send_image(
|
|
self,
|
|
channel_id: str,
|
|
image_data: bytes,
|
|
filename: str = "image.png",
|
|
caption: Optional[str] = None,
|
|
delivery_context: Optional[dict] = None,
|
|
):
|
|
"""Send photo via Telegram sendPhoto."""
|
|
if not self.session:
|
|
return
|
|
|
|
import aiohttp # Lazy import safe here as we have session
|
|
|
|
url = f"{self.base_url}/sendPhoto"
|
|
data = aiohttp.FormData()
|
|
data.add_field("chat_id", channel_id)
|
|
thread_id = self._thread_id_from_context(delivery_context)
|
|
if thread_id is not None:
|
|
data.add_field("message_thread_id", str(thread_id))
|
|
elif delivery_context and delivery_context.get("thread_id"):
|
|
await self._send_thread_diagnostic(
|
|
channel_id, delivery_context.get("thread_id")
|
|
)
|
|
if caption:
|
|
data.add_field("caption", caption)
|
|
|
|
data.add_field("photo", image_data, filename=filename, content_type="image/png")
|
|
|
|
try:
|
|
async with self.session.post(url, data=data) as resp:
|
|
if resp.status != 200:
|
|
err = await resp.text()
|
|
logger.error(f"Telegram send_image failed: {resp.status} {err}")
|
|
except Exception as e:
|
|
logger.error(f"Telegram send_image error: {e}")
|
|
|
|
async def send_message(
|
|
self,
|
|
channel_id: str,
|
|
text: str,
|
|
delivery_context: Optional[dict] = None,
|
|
):
|
|
"""Send text message."""
|
|
if not self.session:
|
|
return
|
|
decision = decide_reply_visibility(
|
|
delivery_context=dict(delivery_context or {}),
|
|
platform="telegram",
|
|
channel_kind=_telegram_channel_kind(channel_id),
|
|
text=text,
|
|
)
|
|
if decision.suppressed:
|
|
logger.info(
|
|
"Suppressed Telegram send_message chat=%s reason=%s",
|
|
channel_id,
|
|
decision.reason,
|
|
)
|
|
return
|
|
|
|
# Reuse internal logic logic but public
|
|
# Using simplified direct call
|
|
url = f"{self.base_url}/sendMessage"
|
|
payload = {"chat_id": channel_id, "text": text}
|
|
thread_id = self._thread_id_from_context(delivery_context)
|
|
if thread_id is not None:
|
|
payload["message_thread_id"] = thread_id
|
|
elif delivery_context and delivery_context.get("thread_id"):
|
|
await self._send_thread_diagnostic(
|
|
channel_id, delivery_context.get("thread_id")
|
|
)
|
|
try:
|
|
async with self.session.post(url, json=payload) as r:
|
|
if r.status != 200:
|
|
err = await r.text()
|
|
logger.error(f"Telegram send_message failed: {r.status} {err}")
|
|
except Exception as e:
|
|
logger.error(f"Telegram send_message error: {e}")
|