feat(agent): Telegram与Agent相互时支持流式输出

This commit is contained in:
jxxghp
2026-03-23 19:13:51 +08:00
parent 9620a06552
commit 4bc67dc816
7 changed files with 1387 additions and 443 deletions

View File

@@ -1,11 +1,16 @@
import asyncio
import re
import threading
from typing import Optional, List, Dict, Callable
from typing import Optional, List, Dict, Callable, Union
from urllib.parse import urljoin, quote
from telebot import TeleBot, apihelper
from telebot.types import BotCommand, InlineKeyboardMarkup, InlineKeyboardButton, InputMediaPhoto
from telebot.types import (
BotCommand,
InlineKeyboardMarkup,
InlineKeyboardButton,
InputMediaPhoto,
)
from telegramify_markdown import standardize, telegramify
from telegramify_markdown.type import ContentTypes, SentType
@@ -25,13 +30,22 @@ class RetryException(Exception):
class Telegram:
_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
_ds_url = (
f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
)
_bot: TeleBot = None
_callback_handlers: Dict[str, Callable] = {} # 存储回调处理器
_user_chat_mapping: Dict[str, str] = {} # userid -> chat_id mapping for reply targeting
_user_chat_mapping: Dict[
str, str
] = {} # userid -> chat_id mapping for reply targeting
_bot_username: Optional[str] = None # Bot username for mention detection
def __init__(self, TELEGRAM_TOKEN: Optional[str] = None, TELEGRAM_CHAT_ID: Optional[str] = None, **kwargs):
def __init__(
self,
TELEGRAM_TOKEN: Optional[str] = None,
TELEGRAM_CHAT_ID: Optional[str] = None,
**kwargs,
):
"""
初始化参数
"""
@@ -46,8 +60,8 @@ class Telegram:
if self._telegram_token and self._telegram_chat_id:
# telegram bot api 地址格式https://api.telegram.org
if kwargs.get("API_URL"):
apihelper.API_URL = urljoin(kwargs["API_URL"], '/bot{0}/{1}')
apihelper.FILE_URL = urljoin(kwargs["API_URL"], '/file/bot{0}/{1}')
apihelper.API_URL = urljoin(kwargs["API_URL"], "/bot{0}/{1}")
apihelper.FILE_URL = urljoin(kwargs["API_URL"], "/file/bot{0}/{1}")
else:
apihelper.proxy = settings.PROXY
# bot
@@ -66,12 +80,15 @@ class Telegram:
# 标记渠道来源
if kwargs.get("name"):
# URL encode the source name to handle special characters
encoded_name = quote(kwargs.get('name'), safe='')
encoded_name = quote(kwargs.get("name"), safe="")
self._ds_url = f"{self._ds_url}&source={encoded_name}"
@_bot.message_handler(commands=['start', 'help'])
@_bot.message_handler(commands=["start", "help"])
def send_welcome(message):
_bot.reply_to(message, "温馨提示:直接发送名称或`订阅`+名称,搜索或订阅电影、电视剧")
_bot.reply_to(
message,
"温馨提示:直接发送名称或`订阅`+名称,搜索或订阅电影、电视剧",
)
@_bot.message_handler(func=lambda message: True)
def echo_all(message):
@@ -82,7 +99,7 @@ class Telegram:
if self._should_process_message(message):
# 发送正在输入状态
try:
_bot.send_chat_action(message.chat.id, 'typing')
_bot.send_chat_action(message.chat.id, "typing")
except Exception as err:
logger.error(f"发送Telegram正在输入状态失败{err}")
RequestUtils(timeout=15).post_res(self._ds_url, json=message.json)
@@ -94,7 +111,9 @@ class Telegram:
"""
try:
# Update user-chat mapping for callbacks too
self._update_user_chat_mapping(call.from_user.id, call.message.chat.id)
self._update_user_chat_mapping(
call.from_user.id, call.message.chat.id
)
# 解析回调数据
callback_data = call.data
@@ -111,9 +130,9 @@ class Telegram:
"message_id": call.message.message_id,
"chat": {
"id": call.message.chat.id,
}
},
},
"data": callback_data
"data": callback_data,
}
}
@@ -122,7 +141,7 @@ class Telegram:
# 发送正在输入状态
try:
_bot.send_chat_action(call.message.chat.id, 'typing')
_bot.send_chat_action(call.message.chat.id, "typing")
except Exception as e:
logger.error(f"发送Telegram正在输入状态失败{e}")
@@ -179,17 +198,17 @@ class Telegram:
:return: 是否处理
"""
# 私聊消息总是处理
if message.chat.type == 'private':
if message.chat.type == "private":
logger.debug(f"处理私聊消息:用户 {message.from_user.id}")
return True
# 群聊中的命令消息总是处理(以/开头)
if message.text and message.text.startswith('/'):
if message.text and message.text.startswith("/"):
logger.debug(f"处理群聊命令消息:{message.text[:20]}...")
return True
# 群聊中检查是否@了机器人
if message.chat.type in ['group', 'supergroup']:
if message.chat.type in ["group", "supergroup"]:
if not self._bot_username:
# 如果没有获取到bot用户名为了安全起见处理所有消息
logger.debug("未获取到bot用户名处理所有群聊消息")
@@ -203,14 +222,20 @@ class Telegram:
# 检查消息实体中是否有提及bot
if message.entities:
for entity in message.entities:
if entity.type == 'mention':
mention_text = message.text[entity.offset:entity.offset + entity.length]
if entity.type == "mention":
mention_text = message.text[
entity.offset : entity.offset + entity.length
]
if mention_text == f"@{self._bot_username}":
logger.debug(f"通过实体检测到@{self._bot_username},处理群聊消息")
logger.debug(
f"通过实体检测到@{self._bot_username},处理群聊消息"
)
return True
# 群聊中没有@机器人,不处理
logger.debug(f"群聊消息未@机器人,跳过处理:{message.text[:30] if message.text else 'No text'}...")
logger.debug(
f"群聊消息未@机器人,跳过处理:{message.text[:30] if message.text else 'No text'}..."
)
return False
# 其他类型的聊天默认处理
@@ -223,11 +248,17 @@ class Telegram:
"""
return self._bot is not None
def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None,
userid: Optional[str] = None, link: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
original_message_id: Optional[int] = None,
original_chat_id: Optional[str] = None) -> Optional[bool]:
def send_msg(
self,
title: str,
text: Optional[str] = None,
image: Optional[str] = None,
userid: Optional[str] = None,
link: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
original_message_id: Optional[int] = None,
original_chat_id: Optional[str] = None,
) -> Optional[dict]:
"""
发送Telegram消息
:param title: 消息标题
@@ -238,14 +269,14 @@ class Telegram:
:param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]]
:param original_message_id: 原消息ID如果提供则编辑原消息
:param original_chat_id: 原消息的聊天ID编辑消息时需要
:return: 包含 message_id, chat_id, success 的字典
"""
if not self._telegram_token or not self._telegram_chat_id:
return None
if not title and not text:
logger.warn("标题和内容不能同时为空")
return False
return {"success": False}
try:
# 标准化标题后再加粗,避免**符号被显示为文本
@@ -275,17 +306,39 @@ class Telegram:
# 判断是编辑消息还是发送新消息
if original_message_id and original_chat_id:
# 编辑消息
return self.__edit_message(original_chat_id, original_message_id, caption, buttons, image)
result = self.__edit_message(
original_chat_id, original_message_id, caption, buttons, image
)
return {
"success": bool(result),
"message_id": original_message_id,
"chat_id": original_chat_id,
}
else:
# 发送新消息
return self.__send_request(userid=chat_id, image=image, caption=caption, reply_markup=reply_markup)
sent = self.__send_request(
userid=chat_id,
image=image,
caption=caption,
reply_markup=reply_markup,
)
if sent and hasattr(sent, "message_id"):
return {
"success": True,
"message_id": sent.message_id,
"chat_id": sent.chat.id if hasattr(sent, "chat") else chat_id,
}
elif sent:
return {"success": True}
return {"success": False}
except Exception as msg_e:
logger.error(f"发送消息失败:{msg_e}")
return False
return {"success": False}
def _determine_target_chat_id(self, userid: Optional[str] = None,
original_chat_id: Optional[str] = None) -> str:
def _determine_target_chat_id(
self, userid: Optional[str] = None, original_chat_id: Optional[str] = None
) -> str:
"""
确定目标聊天ID使用用户映射确保回复到正确的聊天
:param userid: 用户ID
@@ -307,11 +360,16 @@ class Telegram:
# 3. 最后使用默认聊天ID
return self._telegram_chat_id
def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None,
title: Optional[str] = None, link: Optional[str] = None,
buttons: Optional[List[List[Dict]]] = None,
original_message_id: Optional[int] = None,
original_chat_id: Optional[str] = None) -> Optional[bool]:
def send_medias_msg(
self,
medias: List[MediaInfo],
userid: Optional[str] = None,
title: Optional[str] = None,
link: Optional[str] = None,
buttons: Optional[List[List[Dict]]] = None,
original_message_id: Optional[int] = None,
original_chat_id: Optional[str] = None,
) -> Optional[bool]:
"""
发送媒体列表消息
:param medias: 媒体信息列表
@@ -331,18 +389,22 @@ class Telegram:
if not image:
image = media.get_message_image()
if media.vote_average:
caption = "%s\n%s. [%s](%s)\n_%s%s_" % (caption,
index,
media.title_year,
media.detail_link,
f"类型:{media.type.value}",
f"评分{media.vote_average}")
caption = "%s\n%s. [%s](%s)\n_%s%s_" % (
caption,
index,
media.title_year,
media.detail_link,
f"类型{media.type.value}",
f"评分:{media.vote_average}",
)
else:
caption = "%s\n%s. [%s](%s)\n_%s_" % (caption,
index,
media.title_year,
media.detail_link,
f"类型:{media.type.value}")
caption = "%s\n%s. [%s](%s)\n_%s_" % (
caption,
index,
media.title_year,
media.detail_link,
f"类型:{media.type.value}",
)
index += 1
if link:
@@ -359,20 +421,32 @@ class Telegram:
# 判断是编辑消息还是发送新消息
if original_message_id and original_chat_id:
# 编辑消息
return self.__edit_message(original_chat_id, original_message_id, caption, buttons, image)
return self.__edit_message(
original_chat_id, original_message_id, caption, buttons, image
)
else:
# 发送新消息
return self.__send_request(userid=chat_id, image=image, caption=caption, reply_markup=reply_markup)
return self.__send_request(
userid=chat_id,
image=image,
caption=caption,
reply_markup=reply_markup,
)
except Exception as msg_e:
logger.error(f"发送消息失败:{msg_e}")
return False
def send_torrents_msg(self, torrents: List[Context],
userid: Optional[str] = None, title: Optional[str] = None,
link: Optional[str] = None, buttons: Optional[List[List[Dict]]] = None,
original_message_id: Optional[int] = None,
original_chat_id: Optional[str] = None) -> Optional[bool]:
def send_torrents_msg(
self,
torrents: List[Context],
userid: Optional[str] = None,
title: Optional[str] = None,
link: Optional[str] = None,
buttons: Optional[List[List[Dict]]] = None,
original_message_id: Optional[int] = None,
original_chat_id: Optional[str] = None,
) -> Optional[bool]:
"""
发送种子列表消息
:param torrents: 种子信息列表
@@ -394,15 +468,19 @@ class Telegram:
site_name = torrent.site_name
meta = MetaInfo(torrent.title, torrent.description)
link = torrent.page_url
title = f"{meta.season_episode} " \
f"{meta.resource_term} " \
f"{meta.video_term} " \
f"{meta.release_group}"
title = (
f"{meta.season_episode} "
f"{meta.resource_term} "
f"{meta.video_term} "
f"{meta.release_group}"
)
title = re.sub(r"\s+", " ", title).strip()
free = torrent.volume_factor
seeder = f"{torrent.seeders}"
caption = f"{caption}\n{index}.【{site_name}】[{title}]({link}) " \
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}"
caption = (
f"{caption}\n{index}.【{site_name}】[{title}]({link}) "
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}"
)
index += 1
if link:
@@ -419,10 +497,17 @@ class Telegram:
# 判断是编辑消息还是发送新消息
if original_message_id and original_chat_id:
# 编辑消息(种子消息通常没有图片)
return self.__edit_message(original_chat_id, original_message_id, caption, buttons, image)
return self.__edit_message(
original_chat_id, original_message_id, caption, buttons, image
)
else:
# 发送新消息
return self.__send_request(userid=chat_id, image=image, caption=caption, reply_markup=reply_markup)
return self.__send_request(
userid=chat_id,
image=image,
caption=caption,
reply_markup=reply_markup,
)
except Exception as msg_e:
logger.error(f"发送消息失败:{msg_e}")
@@ -444,13 +529,19 @@ class Telegram:
btn = InlineKeyboardButton(text=button["text"], url=button["url"])
else:
# 回调按钮
btn = InlineKeyboardButton(text=button["text"], callback_data=button["callback_data"])
btn = InlineKeyboardButton(
text=button["text"], callback_data=button["callback_data"]
)
button_row.append(btn)
keyboard.append(button_row)
return InlineKeyboardMarkup(keyboard)
def answer_callback_query(self, callback_query_id: int, text: Optional[str] = None,
show_alert: bool = False) -> Optional[bool]:
def answer_callback_query(
self,
callback_query_id: int,
text: Optional[str] = None,
show_alert: bool = False,
) -> Optional[bool]:
"""
回应回调查询
"""
@@ -458,13 +549,17 @@ class Telegram:
return None
try:
self._bot.answer_callback_query(callback_query_id, text=text, show_alert=show_alert)
self._bot.answer_callback_query(
callback_query_id, text=text, show_alert=show_alert
)
return True
except Exception as e:
logger.error(f"回应回调查询失败:{str(e)}")
return False
def delete_msg(self, message_id: int, chat_id: Optional[int] = None) -> Optional[bool]:
def delete_msg(
self, message_id: int, chat_id: Optional[int] = None
) -> Optional[bool]:
"""
删除Telegram消息
:param message_id: 消息ID
@@ -482,20 +577,68 @@ class Telegram:
target_chat_id = self._telegram_chat_id
# 删除消息
result = self._bot.delete_message(chat_id=target_chat_id, message_id=int(message_id))
result = self._bot.delete_message(
chat_id=target_chat_id, message_id=int(message_id)
)
if result:
logger.info(f"成功删除Telegram消息: chat_id={target_chat_id}, message_id={message_id}")
logger.info(
f"成功删除Telegram消息: chat_id={target_chat_id}, message_id={message_id}"
)
return True
else:
logger.error(f"删除Telegram消息失败: chat_id={target_chat_id}, message_id={message_id}")
logger.error(
f"删除Telegram消息失败: chat_id={target_chat_id}, message_id={message_id}"
)
return False
except Exception as e:
logger.error(f"删除Telegram消息异常: {str(e)}")
return False
def __edit_message(self, chat_id: str, message_id: int, text: str,
buttons: Optional[List[List[dict]]] = None,
image: Optional[str] = None) -> Optional[bool]:
def edit_msg(
self,
chat_id: Union[str, int],
message_id: Union[str, int],
text: str,
title: Optional[str] = None,
) -> Optional[bool]:
"""
编辑Telegram消息公开方法
:param chat_id: 聊天ID
:param message_id: 消息ID
:param text: 新的消息内容
:param title: 消息标题
:return: 编辑是否成功
"""
if not self._bot:
return None
try:
# 组合标题和文本
if title:
bold_title = f"**{standardize(title).removesuffix(chr(10))}**"
caption = f"{bold_title}\n{text}" if text else bold_title
elif text:
caption = text
else:
return False
return self.__edit_message(
chat_id=str(chat_id),
message_id=int(message_id),
text=caption,
)
except Exception as e:
logger.error(f"编辑Telegram消息异常: {str(e)}")
return False
def __edit_message(
self,
chat_id: str,
message_id: int,
text: str,
buttons: Optional[List[List[dict]]] = None,
image: Optional[str] = None,
) -> Optional[bool]:
"""
编辑已发送的消息
:param chat_id: 聊天ID
@@ -509,7 +652,6 @@ class Telegram:
return None
try:
# 创建按钮键盘
reply_markup = None
if buttons:
@@ -517,12 +659,14 @@ class Telegram:
if image:
# 如果有图片使用edit_message_media
media = InputMediaPhoto(media=image, caption=standardize(text), parse_mode="MarkdownV2")
media = InputMediaPhoto(
media=image, caption=standardize(text), parse_mode="MarkdownV2"
)
self._bot.edit_message_media(
chat_id=chat_id,
message_id=message_id,
media=media,
reply_markup=reply_markup
reply_markup=reply_markup,
)
else:
# 如果没有图片使用edit_message_text
@@ -531,23 +675,29 @@ class Telegram:
message_id=message_id,
text=standardize(text),
parse_mode="MarkdownV2",
reply_markup=reply_markup
reply_markup=reply_markup,
)
return True
except Exception as e:
logger.error(f"编辑消息失败:{str(e)}")
return False
def __send_request(self, userid: Optional[str] = None, image="", caption="",
reply_markup: Optional[InlineKeyboardMarkup] = None) -> bool:
def __send_request(
self,
userid: Optional[str] = None,
image="",
caption="",
reply_markup: Optional[InlineKeyboardMarkup] = None,
):
"""
向Telegram发送报文
向Telegram发送报文,返回发送的消息对象
:param reply_markup: 内联键盘
:return: 发送成功返回消息对象失败返回None
"""
kwargs = {
'chat_id': userid or self._telegram_chat_id,
'parse_mode': "MarkdownV2",
'reply_markup': reply_markup
"chat_id": userid or self._telegram_chat_id,
"parse_mode": "MarkdownV2",
"reply_markup": reply_markup,
}
# 处理图片
@@ -562,10 +712,10 @@ class Telegram:
sent_idx = set()
ret = self.__send_long_message(image, caption, sent_idx, **kwargs)
return ret is not None
return ret
except Exception as e:
logger.error(f"发送Telegram消息失败: {e}")
return False
return None
@staticmethod
def __process_image(image_url: Optional[str]) -> Optional[bytes]:
@@ -587,27 +737,28 @@ class Telegram:
try:
if image:
return self._bot.send_photo(
photo=image,
caption=standardize(caption),
**kwargs
photo=image, caption=standardize(caption), **kwargs
)
else:
return self._bot.send_message(
text=standardize(caption),
**kwargs
)
return self._bot.send_message(text=standardize(caption), **kwargs)
except Exception:
raise RetryException(f"发送{'图片' if image else '文本'}消息失败")
@retry(RetryException, logger=logger)
def __send_long_message(self, image: Optional[bytes], caption: str, sent_idx: set, **kwargs):
def __send_long_message(
self, image: Optional[bytes], caption: str, sent_idx: set, **kwargs
):
"""
发送长消息
"""
try:
reply_markup = kwargs.pop("reply_markup", None)
boxs: SentType = ThreadHelper().submit(lambda x: asyncio.run(telegramify(x)), caption).result()
boxs: SentType = (
ThreadHelper()
.submit(lambda x: asyncio.run(telegramify(x)), caption)
.result()
)
ret = None
for i, item in enumerate(boxs):
@@ -618,24 +769,27 @@ class Telegram:
current_reply_markup = reply_markup if i == 0 else None
if item.content_type == ContentTypes.TEXT and (i != 0 or not image):
ret = self._bot.send_message(**kwargs,
text=item.content,
reply_markup=current_reply_markup
ret = self._bot.send_message(
**kwargs, text=item.content, reply_markup=current_reply_markup
)
elif item.content_type == ContentTypes.PHOTO or (image and i == 0):
ret = self._bot.send_photo(**kwargs,
photo=(getattr(item, "file_name", ""),
getattr(item, "file_data", image)),
ret = self._bot.send_photo(
**kwargs,
photo=(
getattr(item, "file_name", ""),
getattr(item, "file_data", image),
),
caption=getattr(item, "caption", item.content),
reply_markup=current_reply_markup
reply_markup=current_reply_markup,
)
elif item.content_type == ContentTypes.FILE:
ret = self._bot.send_document(**kwargs,
ret = self._bot.send_document(
**kwargs,
document=(item.file_name, item.file_data),
caption=item.caption,
reply_markup=current_reply_markup
reply_markup=current_reply_markup,
)
sent_idx.add(i)
@@ -658,8 +812,8 @@ class Telegram:
self._bot.delete_my_commands()
self._bot.set_my_commands(
commands=[
BotCommand(cmd[1:], str(desc.get("description"))) for cmd, desc in
commands.items()
BotCommand(cmd[1:], str(desc.get("description")))
for cmd, desc in commands.items()
]
)