mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 11:37:23 +08:00
feat(agent): Telegram与Agent相互时支持流式输出
This commit is contained in:
+52
-39
@@ -5,7 +5,8 @@ from typing import Dict, List
|
|||||||
|
|
||||||
from langchain.agents import create_agent
|
from langchain.agents import create_agent
|
||||||
from langchain.agents.middleware import (
|
from langchain.agents.middleware import (
|
||||||
SummarizationMiddleware, LLMToolSelectorMiddleware,
|
SummarizationMiddleware,
|
||||||
|
LLMToolSelectorMiddleware,
|
||||||
)
|
)
|
||||||
from langchain_core.messages import (
|
from langchain_core.messages import (
|
||||||
HumanMessage,
|
HumanMessage,
|
||||||
@@ -36,12 +37,12 @@ class MoviePilotAgent:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
user_id: str = None,
|
user_id: str = None,
|
||||||
channel: str = None,
|
channel: str = None,
|
||||||
source: str = None,
|
source: str = None,
|
||||||
username: str = None,
|
username: str = None,
|
||||||
):
|
):
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
@@ -80,9 +81,7 @@ class MoviePilotAgent:
|
|||||||
# 系统提示词
|
# 系统提示词
|
||||||
system_prompt = prompt_manager.get_agent_prompt(
|
system_prompt = prompt_manager.get_agent_prompt(
|
||||||
channel=self.channel
|
channel=self.channel
|
||||||
).format(
|
).format(current_date=strftime("%Y-%m-%d"))
|
||||||
current_date=strftime('%Y-%m-%d')
|
|
||||||
)
|
|
||||||
|
|
||||||
# LLM 模型(用于 agent 执行)
|
# LLM 模型(用于 agent 执行)
|
||||||
llm = self._initialize_llm()
|
llm = self._initialize_llm()
|
||||||
@@ -93,21 +92,15 @@ class MoviePilotAgent:
|
|||||||
# 中间件
|
# 中间件
|
||||||
middlewares = [
|
middlewares = [
|
||||||
# 工具选择
|
# 工具选择
|
||||||
LLMToolSelectorMiddleware(
|
LLMToolSelectorMiddleware(model=llm, max_tools=20),
|
||||||
model=llm,
|
|
||||||
max_tools=20
|
|
||||||
),
|
|
||||||
# 记忆管理
|
# 记忆管理
|
||||||
MemoryMiddleware(
|
MemoryMiddleware(
|
||||||
sources=[str(settings.CONFIG_PATH / "agent" / "MEMORY.md")]
|
sources=[str(settings.CONFIG_PATH / "agent" / "MEMORY.md")]
|
||||||
),
|
),
|
||||||
# 上下文压缩
|
# 上下文压缩
|
||||||
SummarizationMiddleware(
|
SummarizationMiddleware(model=llm, trigger=("fraction", 0.85)),
|
||||||
model=llm,
|
|
||||||
trigger=("fraction", 0.85)
|
|
||||||
),
|
|
||||||
# 错误工具调用修复
|
# 错误工具调用修复
|
||||||
PatchToolCallsMiddleware()
|
PatchToolCallsMiddleware(),
|
||||||
]
|
]
|
||||||
|
|
||||||
return create_agent(
|
return create_agent(
|
||||||
@@ -130,8 +123,7 @@ class MoviePilotAgent:
|
|||||||
|
|
||||||
# 获取历史消息
|
# 获取历史消息
|
||||||
messages = memory_manager.get_agent_messages(
|
messages = memory_manager.get_agent_messages(
|
||||||
session_id=self.session_id,
|
session_id=self.session_id, user_id=self.user_id
|
||||||
user_id=self.user_id
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 增加用户消息
|
# 增加用户消息
|
||||||
@@ -150,6 +142,7 @@ class MoviePilotAgent:
|
|||||||
"""
|
"""
|
||||||
调用 LangGraph Agent,通过 astream_events 流式获取 token,
|
调用 LangGraph Agent,通过 astream_events 流式获取 token,
|
||||||
同时用 UsageMetadataCallbackHandler 统计 token 用量。
|
同时用 UsageMetadataCallbackHandler 统计 token 用量。
|
||||||
|
支持流式输出:在支持消息编辑的渠道上实时推送 token。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Agent运行配置
|
# Agent运行配置
|
||||||
@@ -162,37 +155,57 @@ class MoviePilotAgent:
|
|||||||
# 创建智能体
|
# 创建智能体
|
||||||
agent = self._create_agent()
|
agent = self._create_agent()
|
||||||
|
|
||||||
|
# 启动流式输出(内部会检查渠道是否支持消息编辑)
|
||||||
|
await self.stream_handler.start_streaming(
|
||||||
|
channel=self.channel,
|
||||||
|
source=self.source,
|
||||||
|
user_id=self.user_id,
|
||||||
|
username=self.username,
|
||||||
|
)
|
||||||
|
|
||||||
# 流式运行智能体
|
# 流式运行智能体
|
||||||
async for chunk in agent.astream(
|
async for chunk in agent.astream(
|
||||||
{"messages": messages},
|
{"messages": messages},
|
||||||
stream_mode="messages",
|
stream_mode="messages",
|
||||||
config=agent_config,
|
config=agent_config,
|
||||||
version="v2"
|
version="v2",
|
||||||
):
|
):
|
||||||
# 处理流式token(过滤工具调用token,只保留模型生成的内容)
|
# 处理流式token(过滤工具调用token,只保留模型生成的内容)
|
||||||
if chunk["type"] == "messages":
|
if chunk["type"] == "messages":
|
||||||
token, metadata = chunk["data"]
|
token, metadata = chunk["data"]
|
||||||
if (token and hasattr(token, "tool_call_chunks")
|
if (
|
||||||
and not token.tool_call_chunks):
|
token
|
||||||
|
and hasattr(token, "tool_call_chunks")
|
||||||
|
and not token.tool_call_chunks
|
||||||
|
):
|
||||||
if token.content:
|
if token.content:
|
||||||
self.stream_handler.emit(token.content)
|
self.stream_handler.emit(token.content)
|
||||||
|
|
||||||
# 发送最终消息给用户
|
# 停止流式输出,返回是否已通过流式编辑发送了所有内容
|
||||||
await self.send_agent_message(
|
all_sent_via_stream = await self.stream_handler.stop_streaming()
|
||||||
self.stream_handler.take()
|
|
||||||
)
|
if not all_sent_via_stream:
|
||||||
|
# 流式输出未能发送全部内容(渠道不支持编辑,或发送失败)
|
||||||
|
# 通过常规方式发送剩余内容
|
||||||
|
remaining_text = await self.stream_handler.take()
|
||||||
|
if remaining_text:
|
||||||
|
await self.send_agent_message(remaining_text)
|
||||||
|
|
||||||
# 保存消息
|
# 保存消息
|
||||||
memory_manager.save_agent_messages(
|
memory_manager.save_agent_messages(
|
||||||
session_id=self.session_id,
|
session_id=self.session_id,
|
||||||
user_id=self.user_id,
|
user_id=self.user_id,
|
||||||
messages=agent.get_state(agent_config).values.get("messages", [])
|
messages=agent.get_state(agent_config).values.get("messages", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
# 确保取消时也停止流式输出
|
||||||
|
await self.stream_handler.stop_streaming()
|
||||||
logger.info(f"Agent执行被取消: session_id={self.session_id}")
|
logger.info(f"Agent执行被取消: session_id={self.session_id}")
|
||||||
return "任务已取消", {}
|
return "任务已取消", {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# 确保异常时也停止流式输出
|
||||||
|
await self.stream_handler.stop_streaming()
|
||||||
logger.error(f"Agent执行失败: {e} - {traceback.format_exc()}")
|
logger.error(f"Agent执行失败: {e} - {traceback.format_exc()}")
|
||||||
return str(e), {}
|
return str(e), {}
|
||||||
|
|
||||||
@@ -243,13 +256,13 @@ class AgentManager:
|
|||||||
self.active_agents.clear()
|
self.active_agents.clear()
|
||||||
|
|
||||||
async def process_message(
|
async def process_message(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
message: str,
|
message: str,
|
||||||
channel: str = None,
|
channel: str = None,
|
||||||
source: str = None,
|
source: str = None,
|
||||||
username: str = None,
|
username: str = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
处理用户消息
|
处理用户消息
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.chain import ChainBase
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
from app.schemas import Notification
|
||||||
|
from app.schemas.message import (
|
||||||
|
MessageResponse,
|
||||||
|
ChannelCapabilityManager,
|
||||||
|
ChannelCapability,
|
||||||
|
)
|
||||||
|
from app.schemas.types import MessageChannel
|
||||||
|
|
||||||
|
|
||||||
|
class _StreamChain(ChainBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class StreamingHandler:
|
class StreamingHandler:
|
||||||
@@ -8,11 +22,39 @@ class StreamingHandler:
|
|||||||
流式Token缓冲管理器
|
流式Token缓冲管理器
|
||||||
|
|
||||||
负责从 LLM 流式 token 中积累文本,供 Agent 在工具调用之间穿插发送中间消息。
|
负责从 LLM 流式 token 中积累文本,供 Agent 在工具调用之间穿插发送中间消息。
|
||||||
|
当启用流式输出时,通过定时编辑消息将新产生的 tokens 实时推送给用户。
|
||||||
|
|
||||||
|
工作流程:
|
||||||
|
1. Agent开始处理时调用 start_streaming(),检查渠道能力并启动定时刷新
|
||||||
|
2. LLM 产生 token 时调用 emit() 积累到缓冲区
|
||||||
|
3. 定时器周期性调用 _flush():
|
||||||
|
- 第一次有内容时发送新消息(通过 send_direct_message 获取 message_id)
|
||||||
|
- 后续有新内容时编辑同一条消息(通过 edit_message)
|
||||||
|
4. 工具调用时 take() 被调用:取走缓冲区内容(如果已流式发送则返回空),
|
||||||
|
重置消息状态以便工具调用后的新内容开启新的流式消息
|
||||||
|
5. Agent最终完成时调用 stop_streaming():执行最后一次刷新,
|
||||||
|
返回是否已通过流式发送完所有内容(调用方据此决定是否还需额外发送)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 流式输出的刷新间隔(秒)
|
||||||
|
FLUSH_INTERVAL = 3.0
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._buffer = ""
|
self._buffer = ""
|
||||||
|
# 流式输出相关状态
|
||||||
|
self._streaming_enabled = False
|
||||||
|
self._flush_task: Optional[asyncio.Task] = None
|
||||||
|
# 当前消息的发送信息(用于编辑消息)
|
||||||
|
self._message_response: Optional[MessageResponse] = None
|
||||||
|
# 已发送给用户的文本(用于追踪增量)
|
||||||
|
self._sent_text = ""
|
||||||
|
# 消息发送所需的上下文信息
|
||||||
|
self._channel: Optional[str] = None
|
||||||
|
self._source: Optional[str] = None
|
||||||
|
self._user_id: Optional[str] = None
|
||||||
|
self._username: Optional[str] = None
|
||||||
|
self._title: str = "MoviePilot助手"
|
||||||
|
|
||||||
def emit(self, token: str):
|
def emit(self, token: str):
|
||||||
"""
|
"""
|
||||||
@@ -21,17 +63,51 @@ class StreamingHandler:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._buffer += token
|
self._buffer += token
|
||||||
|
|
||||||
def take(self) -> str:
|
async def take(self) -> str:
|
||||||
"""
|
"""
|
||||||
获取当前已积累的消息内容,获取后清空缓冲区。
|
获取当前已积累的消息内容,获取后清空缓冲区。
|
||||||
|
|
||||||
|
当流式输出启用时:
|
||||||
|
1. 先暂停 flush loop(避免与后续发送产生竞争)
|
||||||
|
2. 执行最终一次 flush(确保已有内容完整推送到流式消息)
|
||||||
|
3. 如果内容已全部通过流式编辑发送给用户,返回空字符串(避免重复发送)
|
||||||
|
4. 重置消息状态,以便工具执行后 LLM 产出的新内容开启新的流式消息
|
||||||
|
5. 重新启动 flush loop(恢复后续流式输出能力)
|
||||||
"""
|
"""
|
||||||
|
if self._streaming_enabled:
|
||||||
|
# 暂停 flush loop
|
||||||
|
await self._cancel_flush_task()
|
||||||
|
# 执行最终一次 flush,确保当前流式消息是完整的
|
||||||
|
await self._flush()
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if not self._buffer:
|
if not self._buffer:
|
||||||
return ""
|
message = ""
|
||||||
message = self._buffer
|
already_sent = False
|
||||||
logger.info(f"Agent消息: {message}")
|
else:
|
||||||
self._buffer = ""
|
message = self._buffer
|
||||||
return message
|
logger.info(f"Agent消息: {message}")
|
||||||
|
|
||||||
|
# 如果流式输出已经把内容发给用户了,工具不需要再发
|
||||||
|
already_sent = (
|
||||||
|
self._streaming_enabled
|
||||||
|
and self._message_response is not None
|
||||||
|
and self._sent_text == self._buffer
|
||||||
|
)
|
||||||
|
|
||||||
|
self._buffer = ""
|
||||||
|
|
||||||
|
# 重置流式消息状态,下次有新内容时会开启新消息
|
||||||
|
self._sent_text = ""
|
||||||
|
self._message_response = None
|
||||||
|
|
||||||
|
# 恢复 flush loop(工具执行完成后 LLM 继续产出 token 时需要)
|
||||||
|
if self._streaming_enabled:
|
||||||
|
await self._restart_flush_loop()
|
||||||
|
|
||||||
|
if already_sent or not message:
|
||||||
|
return ""
|
||||||
|
return message
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
"""
|
"""
|
||||||
@@ -39,3 +115,196 @@ class StreamingHandler:
|
|||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._buffer = ""
|
self._buffer = ""
|
||||||
|
self._sent_text = ""
|
||||||
|
self._message_response = None
|
||||||
|
|
||||||
|
async def start_streaming(
|
||||||
|
self,
|
||||||
|
channel: Optional[str] = None,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
title: str = "MoviePilot助手",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
启动流式输出。检查渠道是否支持消息编辑,如果支持则启动定时刷新任务。
|
||||||
|
:param channel: 消息渠道
|
||||||
|
:param source: 消息来源
|
||||||
|
:param user_id: 用户ID
|
||||||
|
:param username: 用户名
|
||||||
|
:param title: 消息标题
|
||||||
|
"""
|
||||||
|
self._channel = channel
|
||||||
|
self._source = source
|
||||||
|
self._user_id = user_id
|
||||||
|
self._username = username
|
||||||
|
self._title = title
|
||||||
|
|
||||||
|
# 检查渠道是否支持消息编辑
|
||||||
|
if not self._can_stream():
|
||||||
|
logger.debug(f"渠道 {channel} 不支持消息编辑,不启用流式输出")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._streaming_enabled = True
|
||||||
|
self._sent_text = ""
|
||||||
|
self._message_response = None
|
||||||
|
|
||||||
|
# 启动异步定时刷新任务
|
||||||
|
self._flush_task = asyncio.create_task(self._flush_loop())
|
||||||
|
logger.debug("流式输出已启动")
|
||||||
|
|
||||||
|
async def stop_streaming(self) -> bool:
|
||||||
|
"""
|
||||||
|
停止流式输出。执行最后一次刷新确保所有内容都已发送。
|
||||||
|
:return: 是否已经通过流式编辑将最终完整内容发送给了用户
|
||||||
|
(True 表示调用方无需再额外发送消息)
|
||||||
|
"""
|
||||||
|
if not self._streaming_enabled:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._streaming_enabled = False
|
||||||
|
|
||||||
|
# 取消定时任务
|
||||||
|
await self._cancel_flush_task()
|
||||||
|
|
||||||
|
# 执行最后一次刷新
|
||||||
|
await self._flush()
|
||||||
|
|
||||||
|
# 检查是否所有缓冲内容都已发送
|
||||||
|
with self._lock:
|
||||||
|
all_sent = (
|
||||||
|
self._message_response is not None
|
||||||
|
and self._sent_text
|
||||||
|
and self._buffer == self._sent_text
|
||||||
|
)
|
||||||
|
# 重置状态
|
||||||
|
self._sent_text = ""
|
||||||
|
self._message_response = None
|
||||||
|
if all_sent:
|
||||||
|
# 所有内容已通过流式发送,清空缓冲区
|
||||||
|
self._buffer = ""
|
||||||
|
return all_sent
|
||||||
|
|
||||||
|
def _can_stream(self) -> bool:
|
||||||
|
"""
|
||||||
|
检查当前渠道是否支持流式输出(消息编辑)
|
||||||
|
"""
|
||||||
|
if not self._channel:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
channel_enum = MessageChannel(self._channel)
|
||||||
|
return ChannelCapabilityManager.supports_capability(
|
||||||
|
channel_enum, ChannelCapability.MESSAGE_EDITING
|
||||||
|
)
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _flush_loop(self):
|
||||||
|
"""
|
||||||
|
定时刷新循环,定期将缓冲区内容发送/编辑到用户
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
while self._streaming_enabled:
|
||||||
|
await asyncio.sleep(self.FLUSH_INTERVAL)
|
||||||
|
if self._streaming_enabled:
|
||||||
|
await self._flush()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"流式刷新异常: {e}")
|
||||||
|
|
||||||
|
async def _cancel_flush_task(self):
|
||||||
|
"""
|
||||||
|
取消当前的定时刷新任务
|
||||||
|
"""
|
||||||
|
if self._flush_task and not self._flush_task.done():
|
||||||
|
self._flush_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._flush_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._flush_task = None
|
||||||
|
|
||||||
|
async def _restart_flush_loop(self):
|
||||||
|
"""
|
||||||
|
重新启动定时刷新任务(用于 take() 后恢复流式输出)
|
||||||
|
"""
|
||||||
|
if not self._streaming_enabled:
|
||||||
|
return
|
||||||
|
self._flush_task = asyncio.create_task(self._flush_loop())
|
||||||
|
|
||||||
|
async def _flush(self):
|
||||||
|
"""
|
||||||
|
将当前缓冲区内容刷新到用户消息
|
||||||
|
- 如果还没有发送过消息,先发送一条新消息并记录message_id
|
||||||
|
- 如果已经发送过消息,编辑该消息为最新的完整内容
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
current_text = self._buffer
|
||||||
|
if not current_text or current_text == self._sent_text:
|
||||||
|
# 没有新内容需要刷新
|
||||||
|
return
|
||||||
|
|
||||||
|
chain = _StreamChain()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._message_response is None:
|
||||||
|
# 第一次发送:发送新消息并获取 message_id
|
||||||
|
response = chain.send_direct_message(
|
||||||
|
Notification(
|
||||||
|
channel=self._channel,
|
||||||
|
source=self._source,
|
||||||
|
userid=self._user_id,
|
||||||
|
username=self._username,
|
||||||
|
title=self._title,
|
||||||
|
text=current_text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if response and response.success and response.message_id:
|
||||||
|
self._message_response = response
|
||||||
|
with self._lock:
|
||||||
|
self._sent_text = current_text
|
||||||
|
logger.debug(
|
||||||
|
f"流式输出初始消息已发送: message_id={response.message_id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"流式输出初始消息发送失败或未返回message_id,降级为非流式输出"
|
||||||
|
)
|
||||||
|
self._streaming_enabled = False
|
||||||
|
else:
|
||||||
|
# 后续更新:编辑已有消息
|
||||||
|
try:
|
||||||
|
channel_enum = MessageChannel(self._channel)
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
return
|
||||||
|
|
||||||
|
success = chain.edit_message(
|
||||||
|
channel=channel_enum,
|
||||||
|
source=self._message_response.source,
|
||||||
|
message_id=self._message_response.message_id,
|
||||||
|
chat_id=self._message_response.chat_id,
|
||||||
|
text=current_text,
|
||||||
|
title=self._title,
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
with self._lock:
|
||||||
|
self._sent_text = current_text
|
||||||
|
else:
|
||||||
|
logger.debug("流式输出消息编辑失败")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"流式输出刷新失败: {e}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_streaming(self) -> bool:
|
||||||
|
"""
|
||||||
|
是否正在流式输出
|
||||||
|
"""
|
||||||
|
return self._streaming_enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_sent_message(self) -> bool:
|
||||||
|
"""
|
||||||
|
是否已经通过流式输出发送过消息(当前轮次)
|
||||||
|
"""
|
||||||
|
return self._message_response is not None
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
# 获取工具调用前 Agent 已积累的流式文本
|
# 获取工具调用前 Agent 已积累的流式文本
|
||||||
agent_message = (
|
agent_message = (
|
||||||
self._stream_handler.take() if self._stream_handler else ""
|
await self._stream_handler.take() if self._stream_handler else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
# 获取工具执行提示消息
|
# 获取工具执行提示消息
|
||||||
|
|||||||
+576
-215
File diff suppressed because it is too large
Load Diff
@@ -8,20 +8,26 @@ from app.core.event import eventmanager
|
|||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.modules import _ModuleBase, _MessageBase
|
from app.modules import _ModuleBase, _MessageBase
|
||||||
from app.modules.telegram.telegram import Telegram
|
from app.modules.telegram.telegram import Telegram
|
||||||
from app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData, \
|
from app.schemas import (
|
||||||
NotificationConf
|
MessageChannel,
|
||||||
|
CommingMessage,
|
||||||
|
Notification,
|
||||||
|
CommandRegisterEventData,
|
||||||
|
NotificationConf,
|
||||||
|
MessageResponse,
|
||||||
|
)
|
||||||
from app.schemas.types import ModuleType, ChainEventType
|
from app.schemas.types import ModuleType, ChainEventType
|
||||||
from app.utils.structures import DictUtils
|
from app.utils.structures import DictUtils
|
||||||
|
|
||||||
|
|
||||||
class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||||
|
|
||||||
def init_module(self) -> None:
|
def init_module(self) -> None:
|
||||||
"""
|
"""
|
||||||
初始化模块
|
初始化模块
|
||||||
"""
|
"""
|
||||||
super().init_service(service_name=Telegram.__name__.lower(),
|
super().init_service(
|
||||||
service_type=Telegram)
|
service_name=Telegram.__name__.lower(), service_type=Telegram
|
||||||
|
)
|
||||||
self._channel = MessageChannel.Telegram
|
self._channel = MessageChannel.Telegram
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -71,8 +77,9 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def message_parser(self, source: str, body: Any, form: Any,
|
def message_parser(
|
||||||
args: Any) -> Optional[CommingMessage]:
|
self, source: str, body: Any, form: Any, args: Any
|
||||||
|
) -> Optional[CommingMessage]:
|
||||||
"""
|
"""
|
||||||
解析消息内容,返回字典,注意以下约定值:
|
解析消息内容,返回字典,注意以下约定值:
|
||||||
userid: 用户ID
|
userid: 用户ID
|
||||||
@@ -140,7 +147,9 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_callback_query(message: dict, client_config: NotificationConf) -> Optional[CommingMessage]:
|
def _handle_callback_query(
|
||||||
|
message: dict, client_config: NotificationConf
|
||||||
|
) -> Optional[CommingMessage]:
|
||||||
"""
|
"""
|
||||||
处理按钮回调查询
|
处理按钮回调查询
|
||||||
"""
|
"""
|
||||||
@@ -151,8 +160,10 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
user_name = user_info.get("username")
|
user_name = user_info.get("username")
|
||||||
|
|
||||||
if callback_data and user_id:
|
if callback_data and user_id:
|
||||||
logger.info(f"收到来自 {client_config.name} 的Telegram按钮回调:"
|
logger.info(
|
||||||
f"userid={user_id}, username={user_name}, callback_data={callback_data}")
|
f"收到来自 {client_config.name} 的Telegram按钮回调:"
|
||||||
|
f"userid={user_id}, username={user_name}, callback_data={callback_data}"
|
||||||
|
)
|
||||||
|
|
||||||
# 将callback_data作为特殊格式的text返回,以便主程序识别这是按钮回调
|
# 将callback_data作为特殊格式的text返回,以便主程序识别这是按钮回调
|
||||||
callback_text = f"CALLBACK:{callback_data}"
|
callback_text = f"CALLBACK:{callback_data}"
|
||||||
@@ -167,13 +178,16 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
is_callback=True,
|
is_callback=True,
|
||||||
callback_data=callback_data,
|
callback_data=callback_data,
|
||||||
message_id=callback_query.get("message", {}).get("message_id"),
|
message_id=callback_query.get("message", {}).get("message_id"),
|
||||||
chat_id=str(callback_query.get("message", {}).get("chat", {}).get("id", "")),
|
chat_id=str(
|
||||||
callback_query=callback_query
|
callback_query.get("message", {}).get("chat", {}).get("id", "")
|
||||||
|
),
|
||||||
|
callback_query=callback_query,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _handle_text_message(self, msg: dict,
|
def _handle_text_message(
|
||||||
client_config: NotificationConf, client: Telegram) -> Optional[CommingMessage]:
|
self, msg: dict, client_config: NotificationConf, client: Telegram
|
||||||
|
) -> Optional[CommingMessage]:
|
||||||
"""
|
"""
|
||||||
处理普通文本消息
|
处理普通文本消息
|
||||||
"""
|
"""
|
||||||
@@ -184,11 +198,15 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
chat_id = msg.get("chat", {}).get("id")
|
chat_id = msg.get("chat", {}).get("id")
|
||||||
|
|
||||||
if text and user_id:
|
if text and user_id:
|
||||||
logger.info(f"收到来自 {client_config.name} 的Telegram消息:"
|
logger.info(
|
||||||
f"userid={user_id}, username={user_name}, chat_id={chat_id}, text={text}")
|
f"收到来自 {client_config.name} 的Telegram消息:"
|
||||||
|
f"userid={user_id}, username={user_name}, chat_id={chat_id}, text={text}"
|
||||||
|
)
|
||||||
|
|
||||||
# Clean bot mentions from text to ensure consistent processing
|
# Clean bot mentions from text to ensure consistent processing
|
||||||
cleaned_text = self._clean_bot_mention(text, client.bot_username if client else None)
|
cleaned_text = self._clean_bot_mention(
|
||||||
|
text, client.bot_username if client else None
|
||||||
|
)
|
||||||
|
|
||||||
# 检查权限
|
# 检查权限
|
||||||
admin_users = client_config.config.get("TELEGRAM_ADMINS")
|
admin_users = client_config.config.get("TELEGRAM_ADMINS")
|
||||||
@@ -196,16 +214,21 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
config_chat_id = client_config.config.get("TELEGRAM_CHAT_ID")
|
config_chat_id = client_config.config.get("TELEGRAM_CHAT_ID")
|
||||||
|
|
||||||
if cleaned_text.startswith("/"):
|
if cleaned_text.startswith("/"):
|
||||||
if admin_users \
|
if (
|
||||||
and str(user_id) not in admin_users.split(',') \
|
admin_users
|
||||||
and str(user_id) != config_chat_id:
|
and str(user_id) not in admin_users.split(",")
|
||||||
client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id)
|
and str(user_id) != config_chat_id
|
||||||
|
):
|
||||||
|
client.send_msg(
|
||||||
|
title="只有管理员才有权限执行此命令", userid=user_id
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
if user_list \
|
if user_list and str(user_id) not in user_list.split(","):
|
||||||
and str(user_id) not in user_list.split(','):
|
|
||||||
logger.info(f"用户{user_id}不在用户白名单中,无法使用此机器人")
|
logger.info(f"用户{user_id}不在用户白名单中,无法使用此机器人")
|
||||||
client.send_msg(title="你不在用户白名单中,无法使用此机器人", userid=user_id)
|
client.send_msg(
|
||||||
|
title="你不在用户白名单中,无法使用此机器人", userid=user_id
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return CommingMessage(
|
return CommingMessage(
|
||||||
@@ -214,7 +237,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
userid=user_id,
|
userid=user_id,
|
||||||
username=user_name,
|
username=user_name,
|
||||||
text=cleaned_text, # Use cleaned text
|
text=cleaned_text, # Use cleaned text
|
||||||
chat_id=str(chat_id) if chat_id else None
|
chat_id=str(chat_id) if chat_id else None,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -235,13 +258,13 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
|
|
||||||
# Remove mention at the beginning with optional following space
|
# Remove mention at the beginning with optional following space
|
||||||
if cleaned.startswith(mention_pattern):
|
if cleaned.startswith(mention_pattern):
|
||||||
cleaned = cleaned[len(mention_pattern):].lstrip()
|
cleaned = cleaned[len(mention_pattern) :].lstrip()
|
||||||
|
|
||||||
# Remove mention at any other position
|
# Remove mention at any other position
|
||||||
cleaned = cleaned.replace(mention_pattern, "").strip()
|
cleaned = cleaned.replace(mention_pattern, "").strip()
|
||||||
|
|
||||||
# Clean up multiple spaces
|
# Clean up multiple spaces
|
||||||
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||||
|
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
@@ -257,19 +280,26 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
targets = message.targets
|
targets = message.targets
|
||||||
userid = message.userid
|
userid = message.userid
|
||||||
if not userid and targets is not None:
|
if not userid and targets is not None:
|
||||||
userid = targets.get('telegram_userid')
|
userid = targets.get("telegram_userid")
|
||||||
if not userid:
|
if not userid:
|
||||||
logger.warn(f"用户没有指定 Telegram用户ID,消息无法发送")
|
logger.warn(f"用户没有指定 Telegram用户ID,消息无法发送")
|
||||||
return
|
return
|
||||||
client: Telegram = self.get_instance(conf.name)
|
client: Telegram = self.get_instance(conf.name)
|
||||||
if client:
|
if client:
|
||||||
client.send_msg(title=message.title, text=message.text,
|
client.send_msg(
|
||||||
image=message.image, userid=userid, link=message.link,
|
title=message.title,
|
||||||
buttons=message.buttons,
|
text=message.text,
|
||||||
original_message_id=message.original_message_id,
|
image=message.image,
|
||||||
original_chat_id=message.original_chat_id)
|
userid=userid,
|
||||||
|
link=message.link,
|
||||||
|
buttons=message.buttons,
|
||||||
|
original_message_id=message.original_message_id,
|
||||||
|
original_chat_id=message.original_chat_id,
|
||||||
|
)
|
||||||
|
|
||||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
def post_medias_message(
|
||||||
|
self, message: Notification, medias: List[MediaInfo]
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
发送媒体信息选择列表
|
发送媒体信息选择列表
|
||||||
:param message: 消息体
|
:param message: 消息体
|
||||||
@@ -281,13 +311,19 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
continue
|
continue
|
||||||
client: Telegram = self.get_instance(conf.name)
|
client: Telegram = self.get_instance(conf.name)
|
||||||
if client:
|
if client:
|
||||||
client.send_medias_msg(title=message.title, medias=medias,
|
client.send_medias_msg(
|
||||||
userid=message.userid, link=message.link,
|
title=message.title,
|
||||||
buttons=message.buttons,
|
medias=medias,
|
||||||
original_message_id=message.original_message_id,
|
userid=message.userid,
|
||||||
original_chat_id=message.original_chat_id)
|
link=message.link,
|
||||||
|
buttons=message.buttons,
|
||||||
|
original_message_id=message.original_message_id,
|
||||||
|
original_chat_id=message.original_chat_id,
|
||||||
|
)
|
||||||
|
|
||||||
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:
|
def post_torrents_message(
|
||||||
|
self, message: Notification, torrents: List[Context]
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
发送种子信息选择列表
|
发送种子信息选择列表
|
||||||
:param message: 消息体
|
:param message: 消息体
|
||||||
@@ -299,14 +335,23 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
continue
|
continue
|
||||||
client: Telegram = self.get_instance(conf.name)
|
client: Telegram = self.get_instance(conf.name)
|
||||||
if client:
|
if client:
|
||||||
client.send_torrents_msg(title=message.title, torrents=torrents,
|
client.send_torrents_msg(
|
||||||
userid=message.userid, link=message.link,
|
title=message.title,
|
||||||
buttons=message.buttons,
|
torrents=torrents,
|
||||||
original_message_id=message.original_message_id,
|
userid=message.userid,
|
||||||
original_chat_id=message.original_chat_id)
|
link=message.link,
|
||||||
|
buttons=message.buttons,
|
||||||
|
original_message_id=message.original_message_id,
|
||||||
|
original_chat_id=message.original_chat_id,
|
||||||
|
)
|
||||||
|
|
||||||
def delete_message(self, channel: MessageChannel, source: str,
|
def delete_message(
|
||||||
message_id: int, chat_id: Optional[int] = None) -> bool:
|
self,
|
||||||
|
channel: MessageChannel,
|
||||||
|
source: str,
|
||||||
|
message_id: int,
|
||||||
|
chat_id: Optional[int] = None,
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
删除消息
|
删除消息
|
||||||
:param channel: 消息渠道
|
:param channel: 消息渠道
|
||||||
@@ -328,6 +373,77 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
success = True
|
success = True
|
||||||
return success
|
return success
|
||||||
|
|
||||||
|
def edit_message(
|
||||||
|
self,
|
||||||
|
channel: MessageChannel,
|
||||||
|
source: str,
|
||||||
|
message_id: Union[str, int],
|
||||||
|
chat_id: Union[str, int],
|
||||||
|
text: str,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
编辑消息
|
||||||
|
:param channel: 消息渠道
|
||||||
|
:param source: 指定的消息源
|
||||||
|
:param message_id: 消息ID
|
||||||
|
:param chat_id: 聊天ID
|
||||||
|
:param text: 新的消息内容
|
||||||
|
:param title: 消息标题
|
||||||
|
:return: 编辑是否成功
|
||||||
|
"""
|
||||||
|
if channel != self._channel:
|
||||||
|
return False
|
||||||
|
for conf in self.get_configs().values():
|
||||||
|
if source != conf.name:
|
||||||
|
continue
|
||||||
|
client: Telegram = self.get_instance(conf.name)
|
||||||
|
if client:
|
||||||
|
result = client.edit_msg(
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=message_id,
|
||||||
|
text=text,
|
||||||
|
title=title,
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_direct_message(self, message: Notification) -> Optional[MessageResponse]:
|
||||||
|
"""
|
||||||
|
直接发送消息并返回消息ID等信息
|
||||||
|
:param message: 消息体
|
||||||
|
:return: 消息响应(包含message_id, chat_id等)
|
||||||
|
"""
|
||||||
|
for conf in self.get_configs().values():
|
||||||
|
if not self.check_message(message, conf.name):
|
||||||
|
continue
|
||||||
|
targets = message.targets
|
||||||
|
userid = message.userid
|
||||||
|
if not userid and targets is not None:
|
||||||
|
userid = targets.get("telegram_userid")
|
||||||
|
if not userid:
|
||||||
|
logger.warn("用户没有指定 Telegram用户ID,消息无法发送")
|
||||||
|
return None
|
||||||
|
client: Telegram = self.get_instance(conf.name)
|
||||||
|
if client:
|
||||||
|
result = client.send_msg(
|
||||||
|
title=message.title,
|
||||||
|
text=message.text,
|
||||||
|
image=message.image,
|
||||||
|
userid=userid,
|
||||||
|
link=message.link,
|
||||||
|
)
|
||||||
|
if result and result.get("success"):
|
||||||
|
return MessageResponse(
|
||||||
|
message_id=result.get("message_id"),
|
||||||
|
chat_id=result.get("chat_id"),
|
||||||
|
channel=MessageChannel.Telegram,
|
||||||
|
source=conf.name,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
def register_commands(self, commands: Dict[str, dict]):
|
def register_commands(self, commands: Dict[str, dict]):
|
||||||
"""
|
"""
|
||||||
注册命令,实现这个函数接收系统可用的命令菜单
|
注册命令,实现这个函数接收系统可用的命令菜单
|
||||||
@@ -342,7 +458,11 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
scoped_commands = copy.deepcopy(commands)
|
scoped_commands = copy.deepcopy(commands)
|
||||||
event = eventmanager.send_event(
|
event = eventmanager.send_event(
|
||||||
ChainEventType.CommandRegister,
|
ChainEventType.CommandRegister,
|
||||||
CommandRegisterEventData(commands=scoped_commands, origin="Telegram", service=client_config.name)
|
CommandRegisterEventData(
|
||||||
|
commands=scoped_commands,
|
||||||
|
origin="Telegram",
|
||||||
|
service=client_config.name,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 如果事件返回有效的 event_data,使用事件中调整后的命令
|
# 如果事件返回有效的 event_data,使用事件中调整后的命令
|
||||||
@@ -361,7 +481,9 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
client.delete_commands()
|
client.delete_commands()
|
||||||
|
|
||||||
# scoped_commands 必须是 commands 的子集
|
# scoped_commands 必须是 commands 的子集
|
||||||
filtered_scoped_commands = DictUtils.filter_keys_to_subset(scoped_commands, commands)
|
filtered_scoped_commands = DictUtils.filter_keys_to_subset(
|
||||||
|
scoped_commands, commands
|
||||||
|
)
|
||||||
# 如果 filtered_scoped_commands 为空,则跳过注册
|
# 如果 filtered_scoped_commands 为空,则跳过注册
|
||||||
if not filtered_scoped_commands:
|
if not filtered_scoped_commands:
|
||||||
logger.debug("Filtered commands are empty, skipping registration.")
|
logger.debug("Filtered commands are empty, skipping registration.")
|
||||||
@@ -369,5 +491,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
continue
|
continue
|
||||||
# 对比调整后的命令与当前命令
|
# 对比调整后的命令与当前命令
|
||||||
if filtered_scoped_commands != commands:
|
if filtered_scoped_commands != commands:
|
||||||
logger.debug(f"Command set has changed, Updating new commands: {filtered_scoped_commands}")
|
logger.debug(
|
||||||
|
f"Command set has changed, Updating new commands: {filtered_scoped_commands}"
|
||||||
|
)
|
||||||
client.register_commands(filtered_scoped_commands)
|
client.register_commands(filtered_scoped_commands)
|
||||||
|
|||||||
+262
-108
@@ -1,11 +1,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional, List, Dict, Callable
|
from typing import Optional, List, Dict, Callable, Union
|
||||||
from urllib.parse import urljoin, quote
|
from urllib.parse import urljoin, quote
|
||||||
|
|
||||||
from telebot import TeleBot, apihelper
|
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 import standardize, telegramify
|
||||||
from telegramify_markdown.type import ContentTypes, SentType
|
from telegramify_markdown.type import ContentTypes, SentType
|
||||||
|
|
||||||
@@ -25,13 +30,22 @@ class RetryException(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class Telegram:
|
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
|
_bot: TeleBot = None
|
||||||
_callback_handlers: Dict[str, Callable] = {} # 存储回调处理器
|
_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
|
_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:
|
if self._telegram_token and self._telegram_chat_id:
|
||||||
# telegram bot api 地址,格式:https://api.telegram.org
|
# telegram bot api 地址,格式:https://api.telegram.org
|
||||||
if kwargs.get("API_URL"):
|
if kwargs.get("API_URL"):
|
||||||
apihelper.API_URL = urljoin(kwargs["API_URL"], '/bot{0}/{1}')
|
apihelper.API_URL = urljoin(kwargs["API_URL"], "/bot{0}/{1}")
|
||||||
apihelper.FILE_URL = urljoin(kwargs["API_URL"], '/file/bot{0}/{1}')
|
apihelper.FILE_URL = urljoin(kwargs["API_URL"], "/file/bot{0}/{1}")
|
||||||
else:
|
else:
|
||||||
apihelper.proxy = settings.PROXY
|
apihelper.proxy = settings.PROXY
|
||||||
# bot
|
# bot
|
||||||
@@ -66,12 +80,15 @@ class Telegram:
|
|||||||
# 标记渠道来源
|
# 标记渠道来源
|
||||||
if kwargs.get("name"):
|
if kwargs.get("name"):
|
||||||
# URL encode the source name to handle special characters
|
# 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}"
|
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):
|
def send_welcome(message):
|
||||||
_bot.reply_to(message, "温馨提示:直接发送名称或`订阅`+名称,搜索或订阅电影、电视剧")
|
_bot.reply_to(
|
||||||
|
message,
|
||||||
|
"温馨提示:直接发送名称或`订阅`+名称,搜索或订阅电影、电视剧",
|
||||||
|
)
|
||||||
|
|
||||||
@_bot.message_handler(func=lambda message: True)
|
@_bot.message_handler(func=lambda message: True)
|
||||||
def echo_all(message):
|
def echo_all(message):
|
||||||
@@ -82,7 +99,7 @@ class Telegram:
|
|||||||
if self._should_process_message(message):
|
if self._should_process_message(message):
|
||||||
# 发送正在输入状态
|
# 发送正在输入状态
|
||||||
try:
|
try:
|
||||||
_bot.send_chat_action(message.chat.id, 'typing')
|
_bot.send_chat_action(message.chat.id, "typing")
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"发送Telegram正在输入状态失败:{err}")
|
logger.error(f"发送Telegram正在输入状态失败:{err}")
|
||||||
RequestUtils(timeout=15).post_res(self._ds_url, json=message.json)
|
RequestUtils(timeout=15).post_res(self._ds_url, json=message.json)
|
||||||
@@ -94,7 +111,9 @@ class Telegram:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Update user-chat mapping for callbacks too
|
# 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
|
callback_data = call.data
|
||||||
@@ -111,9 +130,9 @@ class Telegram:
|
|||||||
"message_id": call.message.message_id,
|
"message_id": call.message.message_id,
|
||||||
"chat": {
|
"chat": {
|
||||||
"id": call.message.chat.id,
|
"id": call.message.chat.id,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"data": callback_data
|
"data": callback_data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +141,7 @@ class Telegram:
|
|||||||
|
|
||||||
# 发送正在输入状态
|
# 发送正在输入状态
|
||||||
try:
|
try:
|
||||||
_bot.send_chat_action(call.message.chat.id, 'typing')
|
_bot.send_chat_action(call.message.chat.id, "typing")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"发送Telegram正在输入状态失败:{e}")
|
logger.error(f"发送Telegram正在输入状态失败:{e}")
|
||||||
|
|
||||||
@@ -179,17 +198,17 @@ class Telegram:
|
|||||||
:return: 是否处理
|
:return: 是否处理
|
||||||
"""
|
"""
|
||||||
# 私聊消息总是处理
|
# 私聊消息总是处理
|
||||||
if message.chat.type == 'private':
|
if message.chat.type == "private":
|
||||||
logger.debug(f"处理私聊消息:用户 {message.from_user.id}")
|
logger.debug(f"处理私聊消息:用户 {message.from_user.id}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 群聊中的命令消息总是处理(以/开头)
|
# 群聊中的命令消息总是处理(以/开头)
|
||||||
if message.text and message.text.startswith('/'):
|
if message.text and message.text.startswith("/"):
|
||||||
logger.debug(f"处理群聊命令消息:{message.text[:20]}...")
|
logger.debug(f"处理群聊命令消息:{message.text[:20]}...")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 群聊中检查是否@了机器人
|
# 群聊中检查是否@了机器人
|
||||||
if message.chat.type in ['group', 'supergroup']:
|
if message.chat.type in ["group", "supergroup"]:
|
||||||
if not self._bot_username:
|
if not self._bot_username:
|
||||||
# 如果没有获取到bot用户名,为了安全起见处理所有消息
|
# 如果没有获取到bot用户名,为了安全起见处理所有消息
|
||||||
logger.debug("未获取到bot用户名,处理所有群聊消息")
|
logger.debug("未获取到bot用户名,处理所有群聊消息")
|
||||||
@@ -203,14 +222,20 @@ class Telegram:
|
|||||||
# 检查消息实体中是否有提及bot
|
# 检查消息实体中是否有提及bot
|
||||||
if message.entities:
|
if message.entities:
|
||||||
for entity in message.entities:
|
for entity in message.entities:
|
||||||
if entity.type == 'mention':
|
if entity.type == "mention":
|
||||||
mention_text = message.text[entity.offset:entity.offset + entity.length]
|
mention_text = message.text[
|
||||||
|
entity.offset : entity.offset + entity.length
|
||||||
|
]
|
||||||
if mention_text == f"@{self._bot_username}":
|
if mention_text == f"@{self._bot_username}":
|
||||||
logger.debug(f"通过实体检测到@{self._bot_username},处理群聊消息")
|
logger.debug(
|
||||||
|
f"通过实体检测到@{self._bot_username},处理群聊消息"
|
||||||
|
)
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
# 其他类型的聊天默认处理
|
# 其他类型的聊天默认处理
|
||||||
@@ -223,11 +248,17 @@ class Telegram:
|
|||||||
"""
|
"""
|
||||||
return self._bot is not None
|
return self._bot is not None
|
||||||
|
|
||||||
def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None,
|
def send_msg(
|
||||||
userid: Optional[str] = None, link: Optional[str] = None,
|
self,
|
||||||
buttons: Optional[List[List[dict]]] = None,
|
title: str,
|
||||||
original_message_id: Optional[int] = None,
|
text: Optional[str] = None,
|
||||||
original_chat_id: Optional[str] = None) -> Optional[bool]:
|
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消息
|
发送Telegram消息
|
||||||
:param title: 消息标题
|
:param title: 消息标题
|
||||||
@@ -238,14 +269,14 @@ class Telegram:
|
|||||||
:param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]]
|
:param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]]
|
||||||
:param original_message_id: 原消息ID,如果提供则编辑原消息
|
:param original_message_id: 原消息ID,如果提供则编辑原消息
|
||||||
:param original_chat_id: 原消息的聊天ID,编辑消息时需要
|
:param original_chat_id: 原消息的聊天ID,编辑消息时需要
|
||||||
|
:return: 包含 message_id, chat_id, success 的字典
|
||||||
"""
|
"""
|
||||||
if not self._telegram_token or not self._telegram_chat_id:
|
if not self._telegram_token or not self._telegram_chat_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not title and not text:
|
if not title and not text:
|
||||||
logger.warn("标题和内容不能同时为空")
|
logger.warn("标题和内容不能同时为空")
|
||||||
return False
|
return {"success": False}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 标准化标题后再加粗,避免**符号被显示为文本
|
# 标准化标题后再加粗,避免**符号被显示为文本
|
||||||
@@ -275,17 +306,39 @@ class Telegram:
|
|||||||
# 判断是编辑消息还是发送新消息
|
# 判断是编辑消息还是发送新消息
|
||||||
if original_message_id and original_chat_id:
|
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:
|
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:
|
except Exception as msg_e:
|
||||||
logger.error(f"发送消息失败:{msg_e}")
|
logger.error(f"发送消息失败:{msg_e}")
|
||||||
return False
|
return {"success": False}
|
||||||
|
|
||||||
def _determine_target_chat_id(self, userid: Optional[str] = None,
|
def _determine_target_chat_id(
|
||||||
original_chat_id: Optional[str] = None) -> str:
|
self, userid: Optional[str] = None, original_chat_id: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
确定目标聊天ID,使用用户映射确保回复到正确的聊天
|
确定目标聊天ID,使用用户映射确保回复到正确的聊天
|
||||||
:param userid: 用户ID
|
:param userid: 用户ID
|
||||||
@@ -307,11 +360,16 @@ class Telegram:
|
|||||||
# 3. 最后使用默认聊天ID
|
# 3. 最后使用默认聊天ID
|
||||||
return self._telegram_chat_id
|
return self._telegram_chat_id
|
||||||
|
|
||||||
def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None,
|
def send_medias_msg(
|
||||||
title: Optional[str] = None, link: Optional[str] = None,
|
self,
|
||||||
buttons: Optional[List[List[Dict]]] = None,
|
medias: List[MediaInfo],
|
||||||
original_message_id: Optional[int] = None,
|
userid: Optional[str] = None,
|
||||||
original_chat_id: Optional[str] = None) -> Optional[bool]:
|
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: 媒体信息列表
|
:param medias: 媒体信息列表
|
||||||
@@ -331,18 +389,22 @@ class Telegram:
|
|||||||
if not image:
|
if not image:
|
||||||
image = media.get_message_image()
|
image = media.get_message_image()
|
||||||
if media.vote_average:
|
if media.vote_average:
|
||||||
caption = "%s\n%s. [%s](%s)\n_%s,%s_" % (caption,
|
caption = "%s\n%s. [%s](%s)\n_%s,%s_" % (
|
||||||
index,
|
caption,
|
||||||
media.title_year,
|
index,
|
||||||
media.detail_link,
|
media.title_year,
|
||||||
f"类型:{media.type.value}",
|
media.detail_link,
|
||||||
f"评分:{media.vote_average}")
|
f"类型:{media.type.value}",
|
||||||
|
f"评分:{media.vote_average}",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
caption = "%s\n%s. [%s](%s)\n_%s_" % (caption,
|
caption = "%s\n%s. [%s](%s)\n_%s_" % (
|
||||||
index,
|
caption,
|
||||||
media.title_year,
|
index,
|
||||||
media.detail_link,
|
media.title_year,
|
||||||
f"类型:{media.type.value}")
|
media.detail_link,
|
||||||
|
f"类型:{media.type.value}",
|
||||||
|
)
|
||||||
index += 1
|
index += 1
|
||||||
|
|
||||||
if link:
|
if link:
|
||||||
@@ -359,20 +421,32 @@ class Telegram:
|
|||||||
# 判断是编辑消息还是发送新消息
|
# 判断是编辑消息还是发送新消息
|
||||||
if original_message_id and original_chat_id:
|
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:
|
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:
|
except Exception as msg_e:
|
||||||
logger.error(f"发送消息失败:{msg_e}")
|
logger.error(f"发送消息失败:{msg_e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def send_torrents_msg(self, torrents: List[Context],
|
def send_torrents_msg(
|
||||||
userid: Optional[str] = None, title: Optional[str] = None,
|
self,
|
||||||
link: Optional[str] = None, buttons: Optional[List[List[Dict]]] = None,
|
torrents: List[Context],
|
||||||
original_message_id: Optional[int] = None,
|
userid: Optional[str] = None,
|
||||||
original_chat_id: Optional[str] = None) -> Optional[bool]:
|
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: 种子信息列表
|
:param torrents: 种子信息列表
|
||||||
@@ -394,15 +468,19 @@ class Telegram:
|
|||||||
site_name = torrent.site_name
|
site_name = torrent.site_name
|
||||||
meta = MetaInfo(torrent.title, torrent.description)
|
meta = MetaInfo(torrent.title, torrent.description)
|
||||||
link = torrent.page_url
|
link = torrent.page_url
|
||||||
title = f"{meta.season_episode} " \
|
title = (
|
||||||
f"{meta.resource_term} " \
|
f"{meta.season_episode} "
|
||||||
f"{meta.video_term} " \
|
f"{meta.resource_term} "
|
||||||
f"{meta.release_group}"
|
f"{meta.video_term} "
|
||||||
|
f"{meta.release_group}"
|
||||||
|
)
|
||||||
title = re.sub(r"\s+", " ", title).strip()
|
title = re.sub(r"\s+", " ", title).strip()
|
||||||
free = torrent.volume_factor
|
free = torrent.volume_factor
|
||||||
seeder = f"{torrent.seeders}↑"
|
seeder = f"{torrent.seeders}↑"
|
||||||
caption = f"{caption}\n{index}.【{site_name}】[{title}]({link}) " \
|
caption = (
|
||||||
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}"
|
f"{caption}\n{index}.【{site_name}】[{title}]({link}) "
|
||||||
|
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}"
|
||||||
|
)
|
||||||
index += 1
|
index += 1
|
||||||
|
|
||||||
if link:
|
if link:
|
||||||
@@ -419,10 +497,17 @@ class Telegram:
|
|||||||
# 判断是编辑消息还是发送新消息
|
# 判断是编辑消息还是发送新消息
|
||||||
if original_message_id and original_chat_id:
|
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:
|
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:
|
except Exception as msg_e:
|
||||||
logger.error(f"发送消息失败:{msg_e}")
|
logger.error(f"发送消息失败:{msg_e}")
|
||||||
@@ -444,13 +529,19 @@ class Telegram:
|
|||||||
btn = InlineKeyboardButton(text=button["text"], url=button["url"])
|
btn = InlineKeyboardButton(text=button["text"], url=button["url"])
|
||||||
else:
|
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)
|
button_row.append(btn)
|
||||||
keyboard.append(button_row)
|
keyboard.append(button_row)
|
||||||
return InlineKeyboardMarkup(keyboard)
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
def answer_callback_query(self, callback_query_id: int, text: Optional[str] = None,
|
def answer_callback_query(
|
||||||
show_alert: bool = False) -> Optional[bool]:
|
self,
|
||||||
|
callback_query_id: int,
|
||||||
|
text: Optional[str] = None,
|
||||||
|
show_alert: bool = False,
|
||||||
|
) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
回应回调查询
|
回应回调查询
|
||||||
"""
|
"""
|
||||||
@@ -458,13 +549,17 @@ class Telegram:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
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
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"回应回调查询失败:{str(e)}")
|
logger.error(f"回应回调查询失败:{str(e)}")
|
||||||
return False
|
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消息
|
删除Telegram消息
|
||||||
:param message_id: 消息ID
|
:param message_id: 消息ID
|
||||||
@@ -482,20 +577,68 @@ class Telegram:
|
|||||||
target_chat_id = self._telegram_chat_id
|
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:
|
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
|
return True
|
||||||
else:
|
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
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"删除Telegram消息异常: {str(e)}")
|
logger.error(f"删除Telegram消息异常: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def __edit_message(self, chat_id: str, message_id: int, text: str,
|
def edit_msg(
|
||||||
buttons: Optional[List[List[dict]]] = None,
|
self,
|
||||||
image: Optional[str] = None) -> Optional[bool]:
|
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
|
:param chat_id: 聊天ID
|
||||||
@@ -509,7 +652,6 @@ class Telegram:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
# 创建按钮键盘
|
# 创建按钮键盘
|
||||||
reply_markup = None
|
reply_markup = None
|
||||||
if buttons:
|
if buttons:
|
||||||
@@ -517,12 +659,14 @@ class Telegram:
|
|||||||
|
|
||||||
if image:
|
if image:
|
||||||
# 如果有图片,使用edit_message_media
|
# 如果有图片,使用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(
|
self._bot.edit_message_media(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
media=media,
|
media=media,
|
||||||
reply_markup=reply_markup
|
reply_markup=reply_markup,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 如果没有图片,使用edit_message_text
|
# 如果没有图片,使用edit_message_text
|
||||||
@@ -531,23 +675,29 @@ class Telegram:
|
|||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
text=standardize(text),
|
text=standardize(text),
|
||||||
parse_mode="MarkdownV2",
|
parse_mode="MarkdownV2",
|
||||||
reply_markup=reply_markup
|
reply_markup=reply_markup,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"编辑消息失败:{str(e)}")
|
logger.error(f"编辑消息失败:{str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def __send_request(self, userid: Optional[str] = None, image="", caption="",
|
def __send_request(
|
||||||
reply_markup: Optional[InlineKeyboardMarkup] = None) -> bool:
|
self,
|
||||||
|
userid: Optional[str] = None,
|
||||||
|
image="",
|
||||||
|
caption="",
|
||||||
|
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
向Telegram发送报文
|
向Telegram发送报文,返回发送的消息对象
|
||||||
:param reply_markup: 内联键盘
|
:param reply_markup: 内联键盘
|
||||||
|
:return: 发送成功返回消息对象,失败返回None
|
||||||
"""
|
"""
|
||||||
kwargs = {
|
kwargs = {
|
||||||
'chat_id': userid or self._telegram_chat_id,
|
"chat_id": userid or self._telegram_chat_id,
|
||||||
'parse_mode': "MarkdownV2",
|
"parse_mode": "MarkdownV2",
|
||||||
'reply_markup': reply_markup
|
"reply_markup": reply_markup,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 处理图片
|
# 处理图片
|
||||||
@@ -562,10 +712,10 @@ class Telegram:
|
|||||||
sent_idx = set()
|
sent_idx = set()
|
||||||
ret = self.__send_long_message(image, caption, sent_idx, **kwargs)
|
ret = self.__send_long_message(image, caption, sent_idx, **kwargs)
|
||||||
|
|
||||||
return ret is not None
|
return ret
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"发送Telegram消息失败: {e}")
|
logger.error(f"发送Telegram消息失败: {e}")
|
||||||
return False
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __process_image(image_url: Optional[str]) -> Optional[bytes]:
|
def __process_image(image_url: Optional[str]) -> Optional[bytes]:
|
||||||
@@ -587,27 +737,28 @@ class Telegram:
|
|||||||
try:
|
try:
|
||||||
if image:
|
if image:
|
||||||
return self._bot.send_photo(
|
return self._bot.send_photo(
|
||||||
photo=image,
|
photo=image, caption=standardize(caption), **kwargs
|
||||||
caption=standardize(caption),
|
|
||||||
**kwargs
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return self._bot.send_message(
|
return self._bot.send_message(text=standardize(caption), **kwargs)
|
||||||
text=standardize(caption),
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
raise RetryException(f"发送{'图片' if image else '文本'}消息失败")
|
raise RetryException(f"发送{'图片' if image else '文本'}消息失败")
|
||||||
|
|
||||||
@retry(RetryException, logger=logger)
|
@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:
|
try:
|
||||||
reply_markup = kwargs.pop("reply_markup", None)
|
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
|
ret = None
|
||||||
for i, item in enumerate(boxs):
|
for i, item in enumerate(boxs):
|
||||||
@@ -618,24 +769,27 @@ class Telegram:
|
|||||||
current_reply_markup = reply_markup if i == 0 else None
|
current_reply_markup = reply_markup if i == 0 else None
|
||||||
|
|
||||||
if item.content_type == ContentTypes.TEXT and (i != 0 or not image):
|
if item.content_type == ContentTypes.TEXT and (i != 0 or not image):
|
||||||
ret = self._bot.send_message(**kwargs,
|
ret = self._bot.send_message(
|
||||||
text=item.content,
|
**kwargs, text=item.content, reply_markup=current_reply_markup
|
||||||
reply_markup=current_reply_markup
|
|
||||||
)
|
)
|
||||||
|
|
||||||
elif item.content_type == ContentTypes.PHOTO or (image and i == 0):
|
elif item.content_type == ContentTypes.PHOTO or (image and i == 0):
|
||||||
ret = self._bot.send_photo(**kwargs,
|
ret = self._bot.send_photo(
|
||||||
photo=(getattr(item, "file_name", ""),
|
**kwargs,
|
||||||
getattr(item, "file_data", image)),
|
photo=(
|
||||||
|
getattr(item, "file_name", ""),
|
||||||
|
getattr(item, "file_data", image),
|
||||||
|
),
|
||||||
caption=getattr(item, "caption", item.content),
|
caption=getattr(item, "caption", item.content),
|
||||||
reply_markup=current_reply_markup
|
reply_markup=current_reply_markup,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif item.content_type == ContentTypes.FILE:
|
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),
|
document=(item.file_name, item.file_data),
|
||||||
caption=item.caption,
|
caption=item.caption,
|
||||||
reply_markup=current_reply_markup
|
reply_markup=current_reply_markup,
|
||||||
)
|
)
|
||||||
|
|
||||||
sent_idx.add(i)
|
sent_idx.add(i)
|
||||||
@@ -658,8 +812,8 @@ class Telegram:
|
|||||||
self._bot.delete_my_commands()
|
self._bot.delete_my_commands()
|
||||||
self._bot.set_my_commands(
|
self._bot.set_my_commands(
|
||||||
commands=[
|
commands=[
|
||||||
BotCommand(cmd[1:], str(desc.get("description"))) for cmd, desc in
|
BotCommand(cmd[1:], str(desc.get("description")))
|
||||||
commands.items()
|
for cmd, desc in commands.items()
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+47
-24
@@ -7,10 +7,28 @@ from pydantic import BaseModel, Field
|
|||||||
from app.schemas.types import ContentType, NotificationType, MessageChannel
|
from app.schemas.types import ContentType, NotificationType, MessageChannel
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
消息发送响应,包含消息ID等信息用于后续编辑
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 消息ID
|
||||||
|
message_id: Optional[Union[str, int]] = None
|
||||||
|
# 聊天ID
|
||||||
|
chat_id: Optional[Union[str, int]] = None
|
||||||
|
# 消息渠道
|
||||||
|
channel: Optional[MessageChannel] = None
|
||||||
|
# 消息来源
|
||||||
|
source: Optional[str] = None
|
||||||
|
# 是否发送成功
|
||||||
|
success: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CommingMessage(BaseModel):
|
class CommingMessage(BaseModel):
|
||||||
"""
|
"""
|
||||||
外来消息
|
外来消息
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 用户ID
|
# 用户ID
|
||||||
userid: Optional[Union[str, int]] = None
|
userid: Optional[Union[str, int]] = None
|
||||||
# 用户名称
|
# 用户名称
|
||||||
@@ -51,6 +69,7 @@ class Notification(BaseModel):
|
|||||||
"""
|
"""
|
||||||
消息
|
消息
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 消息渠道
|
# 消息渠道
|
||||||
channel: Optional[MessageChannel] = None
|
channel: Optional[MessageChannel] = None
|
||||||
# 消息来源
|
# 消息来源
|
||||||
@@ -90,8 +109,7 @@ class Notification(BaseModel):
|
|||||||
"""
|
"""
|
||||||
items = self.model_dump()
|
items = self.model_dump()
|
||||||
for k, v in items.items():
|
for k, v in items.items():
|
||||||
if isinstance(v, MessageChannel) \
|
if isinstance(v, MessageChannel) or isinstance(v, NotificationType):
|
||||||
or isinstance(v, NotificationType):
|
|
||||||
items[k] = v.value
|
items[k] = v.value
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@@ -100,6 +118,7 @@ class NotificationSwitch(BaseModel):
|
|||||||
"""
|
"""
|
||||||
消息开关
|
消息开关
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 消息类型
|
# 消息类型
|
||||||
mtype: Optional[str] = None
|
mtype: Optional[str] = None
|
||||||
# 微信开关
|
# 微信开关
|
||||||
@@ -122,6 +141,7 @@ class Subscription(BaseModel):
|
|||||||
"""
|
"""
|
||||||
客户端消息订阅
|
客户端消息订阅
|
||||||
"""
|
"""
|
||||||
|
|
||||||
endpoint: Optional[str] = None
|
endpoint: Optional[str] = None
|
||||||
keys: Optional[dict] = Field(default_factory=dict)
|
keys: Optional[dict] = Field(default_factory=dict)
|
||||||
|
|
||||||
@@ -130,6 +150,7 @@ class SubscriptionMessage(BaseModel):
|
|||||||
"""
|
"""
|
||||||
客户端订阅消息体
|
客户端订阅消息体
|
||||||
"""
|
"""
|
||||||
|
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
body: Optional[str] = None
|
body: Optional[str] = None
|
||||||
icon: Optional[str] = None
|
icon: Optional[str] = None
|
||||||
@@ -141,6 +162,7 @@ class ChannelCapability(Enum):
|
|||||||
"""
|
"""
|
||||||
渠道能力枚举
|
渠道能力枚举
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 支持内联按钮
|
# 支持内联按钮
|
||||||
INLINE_BUTTONS = "inline_buttons"
|
INLINE_BUTTONS = "inline_buttons"
|
||||||
# 支持菜单命令
|
# 支持菜单命令
|
||||||
@@ -166,6 +188,7 @@ class ChannelCapabilities:
|
|||||||
"""
|
"""
|
||||||
渠道能力配置
|
渠道能力配置
|
||||||
"""
|
"""
|
||||||
|
|
||||||
channel: MessageChannel
|
channel: MessageChannel
|
||||||
capabilities: Set[ChannelCapability]
|
capabilities: Set[ChannelCapability]
|
||||||
max_buttons_per_row: int = 5
|
max_buttons_per_row: int = 5
|
||||||
@@ -191,20 +214,20 @@ class ChannelCapabilityManager:
|
|||||||
ChannelCapability.RICH_TEXT,
|
ChannelCapability.RICH_TEXT,
|
||||||
ChannelCapability.IMAGES,
|
ChannelCapability.IMAGES,
|
||||||
ChannelCapability.LINKS,
|
ChannelCapability.LINKS,
|
||||||
ChannelCapability.FILE_SENDING
|
ChannelCapability.FILE_SENDING,
|
||||||
},
|
},
|
||||||
max_buttons_per_row=4,
|
max_buttons_per_row=4,
|
||||||
max_button_rows=10,
|
max_button_rows=10,
|
||||||
max_button_text_length=30
|
max_button_text_length=30,
|
||||||
),
|
),
|
||||||
MessageChannel.Wechat: ChannelCapabilities(
|
MessageChannel.Wechat: ChannelCapabilities(
|
||||||
channel=MessageChannel.Wechat,
|
channel=MessageChannel.Wechat,
|
||||||
capabilities={
|
capabilities={
|
||||||
ChannelCapability.IMAGES,
|
ChannelCapability.IMAGES,
|
||||||
ChannelCapability.LINKS,
|
ChannelCapability.LINKS,
|
||||||
ChannelCapability.MENU_COMMANDS
|
ChannelCapability.MENU_COMMANDS,
|
||||||
},
|
},
|
||||||
fallback_enabled=True
|
fallback_enabled=True,
|
||||||
),
|
),
|
||||||
MessageChannel.Slack: ChannelCapabilities(
|
MessageChannel.Slack: ChannelCapabilities(
|
||||||
channel=MessageChannel.Slack,
|
channel=MessageChannel.Slack,
|
||||||
@@ -216,12 +239,12 @@ class ChannelCapabilityManager:
|
|||||||
ChannelCapability.RICH_TEXT,
|
ChannelCapability.RICH_TEXT,
|
||||||
ChannelCapability.IMAGES,
|
ChannelCapability.IMAGES,
|
||||||
ChannelCapability.LINKS,
|
ChannelCapability.LINKS,
|
||||||
ChannelCapability.MENU_COMMANDS
|
ChannelCapability.MENU_COMMANDS,
|
||||||
},
|
},
|
||||||
max_buttons_per_row=3,
|
max_buttons_per_row=3,
|
||||||
max_button_rows=8,
|
max_button_rows=8,
|
||||||
max_button_text_length=25,
|
max_button_text_length=25,
|
||||||
fallback_enabled=True
|
fallback_enabled=True,
|
||||||
),
|
),
|
||||||
MessageChannel.Discord: ChannelCapabilities(
|
MessageChannel.Discord: ChannelCapabilities(
|
||||||
channel=MessageChannel.Discord,
|
channel=MessageChannel.Discord,
|
||||||
@@ -232,56 +255,54 @@ class ChannelCapabilityManager:
|
|||||||
ChannelCapability.CALLBACK_QUERIES,
|
ChannelCapability.CALLBACK_QUERIES,
|
||||||
ChannelCapability.RICH_TEXT,
|
ChannelCapability.RICH_TEXT,
|
||||||
ChannelCapability.IMAGES,
|
ChannelCapability.IMAGES,
|
||||||
ChannelCapability.LINKS
|
ChannelCapability.LINKS,
|
||||||
},
|
},
|
||||||
max_buttons_per_row=5,
|
max_buttons_per_row=5,
|
||||||
max_button_rows=5,
|
max_button_rows=5,
|
||||||
max_button_text_length=80,
|
max_button_text_length=80,
|
||||||
fallback_enabled=True
|
fallback_enabled=True,
|
||||||
),
|
),
|
||||||
MessageChannel.SynologyChat: ChannelCapabilities(
|
MessageChannel.SynologyChat: ChannelCapabilities(
|
||||||
channel=MessageChannel.SynologyChat,
|
channel=MessageChannel.SynologyChat,
|
||||||
capabilities={
|
capabilities={
|
||||||
ChannelCapability.RICH_TEXT,
|
ChannelCapability.RICH_TEXT,
|
||||||
ChannelCapability.IMAGES,
|
ChannelCapability.IMAGES,
|
||||||
ChannelCapability.LINKS
|
ChannelCapability.LINKS,
|
||||||
},
|
},
|
||||||
fallback_enabled=True
|
fallback_enabled=True,
|
||||||
),
|
),
|
||||||
MessageChannel.VoceChat: ChannelCapabilities(
|
MessageChannel.VoceChat: ChannelCapabilities(
|
||||||
channel=MessageChannel.VoceChat,
|
channel=MessageChannel.VoceChat,
|
||||||
capabilities={
|
capabilities={
|
||||||
ChannelCapability.RICH_TEXT,
|
ChannelCapability.RICH_TEXT,
|
||||||
ChannelCapability.IMAGES,
|
ChannelCapability.IMAGES,
|
||||||
ChannelCapability.LINKS
|
ChannelCapability.LINKS,
|
||||||
},
|
},
|
||||||
fallback_enabled=True
|
fallback_enabled=True,
|
||||||
),
|
),
|
||||||
MessageChannel.WebPush: ChannelCapabilities(
|
MessageChannel.WebPush: ChannelCapabilities(
|
||||||
channel=MessageChannel.WebPush,
|
channel=MessageChannel.WebPush,
|
||||||
capabilities={
|
capabilities={ChannelCapability.LINKS},
|
||||||
ChannelCapability.LINKS
|
fallback_enabled=True,
|
||||||
},
|
|
||||||
fallback_enabled=True
|
|
||||||
),
|
),
|
||||||
MessageChannel.Web: ChannelCapabilities(
|
MessageChannel.Web: ChannelCapabilities(
|
||||||
channel=MessageChannel.Web,
|
channel=MessageChannel.Web,
|
||||||
capabilities={
|
capabilities={
|
||||||
ChannelCapability.RICH_TEXT,
|
ChannelCapability.RICH_TEXT,
|
||||||
ChannelCapability.IMAGES,
|
ChannelCapability.IMAGES,
|
||||||
ChannelCapability.LINKS
|
ChannelCapability.LINKS,
|
||||||
},
|
},
|
||||||
fallback_enabled=True
|
fallback_enabled=True,
|
||||||
),
|
),
|
||||||
MessageChannel.QQ: ChannelCapabilities(
|
MessageChannel.QQ: ChannelCapabilities(
|
||||||
channel=MessageChannel.QQ,
|
channel=MessageChannel.QQ,
|
||||||
capabilities={
|
capabilities={
|
||||||
ChannelCapability.RICH_TEXT,
|
ChannelCapability.RICH_TEXT,
|
||||||
ChannelCapability.IMAGES,
|
ChannelCapability.IMAGES,
|
||||||
ChannelCapability.LINKS
|
ChannelCapability.LINKS,
|
||||||
},
|
},
|
||||||
fallback_enabled=True
|
fallback_enabled=True,
|
||||||
)
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -292,7 +313,9 @@ class ChannelCapabilityManager:
|
|||||||
return cls._capabilities.get(channel)
|
return cls._capabilities.get(channel)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def supports_capability(cls, channel: MessageChannel, capability: ChannelCapability) -> bool:
|
def supports_capability(
|
||||||
|
cls, channel: MessageChannel, capability: ChannelCapability
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
检查渠道是否支持某项能力
|
检查渠道是否支持某项能力
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user