feat(message-processing-status): unified processing status indicator for Telegram, Slack, Discord, Feishu

- Add ChannelCapability.PROCESSING_STATUS and capability detection for supported channels
- Implement mark_message_processing_started/finished in Telegram, Slack, Discord, Feishu modules
  - Telegram: manage typing lifecycle with max duration and explicit stop
  - Slack: add/remove reaction as processing indicator
  - Discord: start/stop typing indicator with async task management
  - Feishu: add/remove reaction for processing status
- Refactor message chain to invoke processing status hooks for supported channels
- Ensure processing status is properly finished on sync and async message handling paths
- Add tests for processing status lifecycle and capability detection across channels
This commit is contained in:
jxxghp
2026-05-15 12:45:41 +08:00
parent 5a06e7b8bc
commit b2a18f9ae4
13 changed files with 1101 additions and 92 deletions

View File

@@ -473,6 +473,69 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
return True
return False
def mark_message_processing_started(
self,
channel: MessageChannel,
source: str,
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
text: Optional[str] = None,
) -> Optional[dict]:
"""
使用 Discord typing 指示标记“正在处理”。
"""
if channel != self._channel:
return None
if not text:
return None
config = self.get_config(source)
if not config:
return None
client: Discord = self.get_instance(config.name)
if not client:
return None
if not client.start_typing(
userid=str(userid) if userid else None,
chat_id=str(chat_id) if chat_id else None,
):
return None
return {
"channel": channel.value,
"source": source,
"userid": userid,
"message_id": str(message_id) if message_id else None,
"chat_id": str(chat_id) if chat_id else None,
"metadata": {"kind": "typing"},
}
def mark_message_processing_finished(
self,
channel: MessageChannel,
source: str,
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
status: Optional[dict] = None,
) -> Optional[bool]:
"""
停止 Discord typing 续发任务。
"""
if channel != self._channel:
return None
target_chat_id = (status or {}).get("chat_id") or chat_id
target_userid = (status or {}).get("userid") or userid
config = self.get_config(source)
if not config:
return False
client: Discord = self.get_instance(config.name)
if not client:
return False
return client.stop_typing(
userid=str(target_userid) if target_userid else None,
chat_id=str(target_chat_id) if target_chat_id else None,
)
def send_direct_message(self, message: Notification) -> Optional[MessageResponse]:
"""
直接发送消息并返回消息ID等信息

View File

@@ -79,6 +79,10 @@ class Discord:
] = {} # userid -> chat_id mapping for reply targeting
self._broadcast_channel = None
self._bot_user_id: Optional[int] = None
self._typing_tasks: Dict[str, asyncio.Task] = {}
self._typing_stop_events: Dict[str, asyncio.Event] = {}
self._typing_interval_seconds = 5
self._typing_max_duration_seconds = 5 * 60
self._register_events()
self._start()
@@ -209,6 +213,9 @@ class Discord:
if not self._client or not self._loop or not self._thread:
return
try:
asyncio.run_coroutine_threadsafe(
self._stop_all_typing_tasks(), self._loop
).result(timeout=5)
asyncio.run_coroutine_threadsafe(self._client.close(), self._loop).result(
timeout=10
)
@@ -367,6 +374,125 @@ class Discord:
logger.error(f"发送 Discord 种子列表失败:{err}")
return False
def start_typing(
self,
userid: Optional[str] = None,
chat_id: Optional[str] = None,
max_duration_seconds: Optional[float] = None,
) -> bool:
"""
持续发送 Discord typing 指示,直到显式停止或达到最大续期。
"""
if not self.get_state():
return False
typing_key = self._typing_key(userid=userid, chat_id=chat_id)
if not typing_key:
return False
try:
future = asyncio.run_coroutine_threadsafe(
self._start_typing_task(
typing_key=typing_key,
userid=userid,
chat_id=chat_id,
max_duration_seconds=max_duration_seconds,
),
self._loop,
)
return future.result(timeout=10)
except Exception as err:
logger.error(f"发送 Discord typing 状态失败:{err}")
return False
def stop_typing(
self,
userid: Optional[str] = None,
chat_id: Optional[str] = None,
) -> bool:
"""
停止 Discord typing 续发任务。
"""
typing_key = self._typing_key(userid=userid, chat_id=chat_id)
if not typing_key or not self._loop:
return False
try:
future = asyncio.run_coroutine_threadsafe(
self._stop_typing_task(typing_key), self._loop
)
return future.result(timeout=5)
except Exception as err:
logger.error(f"停止 Discord typing 状态失败:{err}")
return False
@staticmethod
def _typing_key(userid: Optional[str] = None, chat_id: Optional[str] = None) -> str:
"""优先按频道维度管理 typing 状态,缺失时退回用户维度。"""
if chat_id:
return f"chat:{chat_id}"
if userid:
return f"user:{userid}"
return ""
async def _start_typing_task(
self,
typing_key: str,
userid: Optional[str] = None,
chat_id: Optional[str] = None,
max_duration_seconds: Optional[float] = None,
) -> bool:
await self._stop_typing_task(typing_key)
channel = await self._resolve_channel(userid=userid, chat_id=chat_id)
if not channel:
return False
stop_event = asyncio.Event()
max_duration = max_duration_seconds or self._typing_max_duration_seconds
async def _typing_worker() -> None:
started_at = self._loop.time()
try:
while not stop_event.is_set():
if self._loop.time() - started_at >= max_duration:
logger.warning(
"Discord typing状态超过最大续期自动停止: key=%s",
typing_key,
)
break
try:
await channel.trigger_typing()
except Exception as err:
logger.debug(f"触发 Discord typing 状态失败:{err}")
try:
await asyncio.wait_for(
stop_event.wait(),
timeout=self._typing_interval_seconds,
)
except asyncio.TimeoutError:
pass
finally:
current_task = asyncio.current_task()
if self._typing_tasks.get(typing_key) is current_task:
self._typing_tasks.pop(typing_key, None)
self._typing_stop_events.pop(typing_key, None)
self._typing_stop_events[typing_key] = stop_event
self._typing_tasks[typing_key] = asyncio.create_task(_typing_worker())
return True
async def _stop_typing_task(self, typing_key: str) -> bool:
stop_event = self._typing_stop_events.pop(typing_key, None)
task = self._typing_tasks.pop(typing_key, None)
if stop_event:
stop_event.set()
if task and task is not asyncio.current_task() and not task.done():
try:
await asyncio.wait_for(asyncio.shield(task), timeout=1)
except asyncio.TimeoutError:
pass
return bool(stop_event or task)
async def _stop_all_typing_tasks(self) -> None:
for typing_key in list(self._typing_tasks.keys()):
await self._stop_typing_task(typing_key)
def delete_msg(
self, message_id: Union[str, int], chat_id: Optional[str] = None
) -> Optional[bool]:

View File

@@ -360,6 +360,67 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
return False
return client.delete_message_reaction(message_id=message_id, reaction_id=reaction_id)
def mark_message_processing_started(
self,
channel: MessageChannel,
source: str,
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
text: Optional[str] = None,
) -> Optional[dict]:
"""
使用飞书消息表情标记“正在处理”。
"""
if channel != self._channel:
return None
if not message_id or not text or str(text).startswith("CALLBACK:"):
return None
reaction_id = self.add_feishu_message_reaction(
message_id=str(message_id),
emoji_type=Feishu.PROCESSING_REACTION_EMOJI,
source=source,
)
if not reaction_id:
return None
return {
"channel": channel.value,
"source": source,
"userid": userid,
"message_id": str(message_id),
"chat_id": str(chat_id) if chat_id else None,
"metadata": {
"kind": "reaction",
"reaction_id": str(reaction_id),
"emoji_type": Feishu.PROCESSING_REACTION_EMOJI,
},
}
def mark_message_processing_finished(
self,
channel: MessageChannel,
source: str,
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
status: Optional[dict] = None,
) -> Optional[bool]:
"""
删除飞书“正在处理”表情。
"""
if channel != self._channel:
return None
metadata = (status or {}).get("metadata") or {}
target_message_id = (status or {}).get("message_id") or message_id
reaction_id = metadata.get("reaction_id")
if not target_message_id or not reaction_id:
return False
return self.delete_feishu_message_reaction(
message_id=str(target_message_id),
reaction_id=str(reaction_id),
source=source,
)
def finalize_message(self, response: MessageResponse) -> bool:
if response.channel != self._channel or not isinstance(response.metadata, dict):
return False

View File

@@ -12,6 +12,7 @@ from app.schemas.types import ModuleType
class SlackModule(_ModuleBase, _MessageBase[Slack]):
PROCESSING_REACTION = "eyes"
_AUDIO_SUFFIXES = (
".mp3",
".m4a",
@@ -222,10 +223,14 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
images = None
audio_refs = None
files = None
message_id = None
chat_id = None
if msg_json.get("type") == "message":
userid = msg_json.get("user")
text = msg_json.get("text")
username = msg_json.get("user")
message_id = msg_json.get("ts")
chat_id = msg_json.get("channel")
images = self._extract_images(msg_json)
audio_refs = self._extract_audio_refs(msg_json)
files = self._extract_files(msg_json)
@@ -270,6 +275,8 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
flags=re.IGNORECASE,
).strip()
username = ""
message_id = msg_json.get("event", {}).get("ts")
chat_id = msg_json.get("event", {}).get("channel")
images = self._extract_images(msg_json.get("event", {}))
audio_refs = self._extract_audio_refs(msg_json.get("event", {}))
files = self._extract_files(msg_json.get("event", {}))
@@ -281,6 +288,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
userid = msg_json.get("user_id")
text = msg_json.get("command")
username = msg_json.get("user_name")
chat_id = msg_json.get("channel_id")
else:
return None
logger.info(
@@ -294,6 +302,8 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
userid=userid,
username=username,
text=text,
message_id=message_id,
chat_id=chat_id,
images=images,
audio_refs=audio_refs,
files=files,
@@ -589,6 +599,78 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
return True
return False
def mark_message_processing_started(
self,
channel: MessageChannel,
source: str,
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
text: Optional[str] = None,
) -> Optional[dict]:
"""
使用 Slack reaction 标记“正在处理”。
"""
if channel != self._channel:
return None
if not message_id or not chat_id or not text or str(text).startswith("CALLBACK:"):
return None
config = self.get_config(source)
if not config:
return None
client: Slack = self.get_instance(config.name)
if not client:
return None
if not client.add_reaction(
channel=str(chat_id),
timestamp=str(message_id),
emoji=self.PROCESSING_REACTION,
):
return None
return {
"channel": channel.value,
"source": source,
"userid": userid,
"message_id": str(message_id),
"chat_id": str(chat_id),
"metadata": {
"kind": "reaction",
"emoji": self.PROCESSING_REACTION,
},
}
def mark_message_processing_finished(
self,
channel: MessageChannel,
source: str,
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
status: Optional[dict] = None,
) -> Optional[bool]:
"""
移除 Slack “正在处理” reaction。
"""
if channel != self._channel:
return None
metadata = (status or {}).get("metadata") or {}
target_message_id = (status or {}).get("message_id") or message_id
target_chat_id = (status or {}).get("chat_id") or chat_id
emoji = metadata.get("emoji") or self.PROCESSING_REACTION
if not target_message_id or not target_chat_id:
return False
config = self.get_config(source)
if not config:
return False
client: Slack = self.get_instance(config.name)
if not client:
return False
return client.remove_reaction(
channel=str(target_chat_id),
timestamp=str(target_message_id),
emoji=str(emoji),
)
def send_direct_message(self, message: Notification) -> Optional[MessageResponse]:
"""
直接发送消息并返回消息ID等信息

View File

@@ -289,6 +289,40 @@ class Slack:
logger.error(f"Slack文件发送失败: {err}")
return False, str(err)
def add_reaction(self, channel: str, timestamp: str, emoji: str) -> bool:
"""
为 Slack 消息添加 reaction用作正在处理状态。
"""
if not self._client or not channel or not timestamp or not emoji:
return False
try:
result = self._client.reactions_add(
channel=channel,
timestamp=timestamp,
name=emoji,
)
return bool(result and result.get("ok", True))
except Exception as err:
logger.error(f"Slack添加reaction失败: {err}")
return False
def remove_reaction(self, channel: str, timestamp: str, emoji: str) -> bool:
"""
移除 Slack 消息 reaction。
"""
if not self._client or not channel or not timestamp or not emoji:
return False
try:
result = self._client.reactions_remove(
channel=channel,
timestamp=timestamp,
name=emoji,
)
return bool(result and result.get("ok", True))
except Exception as err:
logger.error(f"Slack移除reaction失败: {err}")
return False
def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
original_message_id: Optional[str] = None,

View File

@@ -596,6 +596,57 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
return True
return False
def mark_message_processing_started(
self,
channel: MessageChannel,
source: str,
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
text: Optional[str] = None,
) -> Optional[dict]:
"""
标记 Telegram 消息正在处理。
入站侧已经启动 typing 任务,这里只返回可用于统一收口的上下文。
"""
if channel != self._channel:
return None
if not text:
return None
return {
"channel": channel.value,
"source": source,
"userid": userid,
"message_id": message_id,
"chat_id": chat_id,
"metadata": {"kind": "typing"},
}
def mark_message_processing_finished(
self,
channel: MessageChannel,
source: str,
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
status: Optional[dict] = None,
) -> Optional[bool]:
"""
结束 Telegram typing 状态。
"""
if channel != self._channel:
return None
if status:
chat_id = status.get("chat_id") or chat_id
userid = status.get("userid") or userid
client_config = self.get_config(source)
if not client_config:
return False
client: Telegram = self.get_instance(client_config.name)
if not client:
return False
return client.stop_typing(chat_id=chat_id, userid=userid)
def send_direct_message(self, message: Notification) -> Optional[MessageResponse]:
"""
直接发送消息并返回消息ID等信息

View File

@@ -43,7 +43,12 @@ class Telegram:
] = {} # userid -> chat_id mapping for reply targeting
_bot_username: Optional[str] = None # Bot username for mention detection
_typing_tasks: Dict[str, threading.Thread] = {} # chat_id -> typing任务
_typing_stop_flags: Dict[str, bool] = {} # chat_id -> 停止标志
_typing_stop_flags: Dict[str, threading.Event] = {} # chat_id -> 停止信号
_typing_lock = threading.RLock()
_typing_interval_seconds = 5
_typing_max_duration_seconds = 5 * 60
_typing_command_max_duration_seconds = 30
_typing_callback_max_duration_seconds = 60
def __init__(
self,
@@ -54,13 +59,13 @@ class Telegram:
"""
初始化参数
"""
# 即使配置不完整也保留基础属性,便于测试和未启用实例安全调用发送方法。
self._telegram_token = TELEGRAM_TOKEN
self._telegram_chat_id = TELEGRAM_CHAT_ID
self._polling_thread = None
if not TELEGRAM_TOKEN or not TELEGRAM_CHAT_ID:
logger.error("Telegram配置不完整")
return
# Token
self._telegram_token = TELEGRAM_TOKEN
# Chat Id
self._telegram_chat_id = TELEGRAM_CHAT_ID
# 初始化机器人
if self._telegram_token and self._telegram_chat_id:
# telegram bot api 地址格式https://api.telegram.org
@@ -114,22 +119,42 @@ class Telegram:
# Check if we should process this message
if self._should_process_message(message):
# 启动持续发送正在输入状态
self._start_typing_task(message.chat.id)
message_text = message.text or message.caption or ""
max_duration = (
self._typing_command_max_duration_seconds
if (
message_text.startswith("/")
and not message_text.lower().startswith("/ai")
)
else None
)
self._start_typing_task(
message.chat.id, max_duration_seconds=max_duration
)
payload = self._serialize_update_payload(message)
if not payload:
logger.warn("Telegram消息序列化失败跳过转发")
self._stop_typing_task(message.chat.id)
return
RequestUtils(timeout=15).post_res(self._ds_url, json=payload)
response = RequestUtils(timeout=15).post_res(
self._ds_url, json=payload
)
if not response or response.status_code >= 400:
logger.warn("Telegram消息转发失败停止typing状态")
self._stop_typing_task(message.chat.id)
@_bot.callback_query_handler(func=lambda call: True)
def callback_query(call):
"""
处理按钮点击回调
"""
chat_id = None
typing_started = False
try:
# Update user-chat mapping for callbacks too
chat_id = call.message.chat.id
self._update_user_chat_mapping(
call.from_user.id, call.message.chat.id
call.from_user.id, chat_id
)
# 解析回调数据
@@ -146,7 +171,7 @@ class Telegram:
"message": {
"message_id": call.message.message_id,
"chat": {
"id": call.message.chat.id,
"id": chat_id,
},
},
"data": callback_data,
@@ -157,13 +182,24 @@ class Telegram:
_bot.answer_callback_query(call.id)
# 启动持续发送正在输入状态
self._start_typing_task(call.message.chat.id)
self._start_typing_task(
chat_id,
max_duration_seconds=self._typing_callback_max_duration_seconds,
)
typing_started = True
# 发送给主程序处理
RequestUtils(timeout=15).post_res(self._ds_url, json=callback_json)
response = RequestUtils(timeout=15).post_res(
self._ds_url, json=callback_json
)
if not response or response.status_code >= 400:
logger.warn("Telegram按钮回调转发失败停止typing状态")
self._stop_typing_task(chat_id)
except Exception as err:
logger.error(f"处理按钮回调失败:{str(err)}")
if typing_started and chat_id is not None:
self._stop_typing_task(chat_id)
_bot.answer_callback_query(call.id, "处理失败,请重试")
def run_polling():
@@ -326,46 +362,85 @@ class Telegram:
"""
return self._bot is not None
def _start_typing_task(self, chat_id: Union[str, int]) -> None:
def _start_typing_task(
self,
chat_id: Union[str, int],
max_duration_seconds: Optional[float] = None,
) -> None:
"""
启动持续发送正在输入状态的任务
"""
chat_id_str = str(chat_id)
# 如果已有任务在运行,先停止
if chat_id_str in self._typing_tasks:
self._stop_typing_task(chat_id_str)
self._stop_typing_task(chat_id_str)
# 设置停止标
self._typing_stop_flags[chat_id_str] = False
# 使用独立 Event 避免同一 chat 新旧 typing 线程互相误改停止标记。
stop_event = threading.Event()
max_duration = max_duration_seconds or self._typing_max_duration_seconds
def typing_worker():
"""定期发送typing状态的后台线程"""
while not self._typing_stop_flags.get(chat_id_str, True):
try:
if self._bot:
self._bot.send_chat_action(chat_id, "typing")
except Exception as e:
logger.debug(f"发送typing状态失败: {e}")
# 每5秒发送一次Telegram客户端会在约5-6秒后消失状态
for _ in range(50):
if self._typing_stop_flags.get(chat_id_str, True):
started_at = time.monotonic()
try:
while not stop_event.is_set():
if time.monotonic() - started_at >= max_duration:
logger.warning(
"Telegram typing状态超过最大续期自动停止: chat_id=%s",
chat_id_str,
)
break
time.sleep(0.1)
try:
if self._bot:
self._bot.send_chat_action(chat_id, "typing")
except Exception as e:
logger.debug(f"发送typing状态失败: {e}")
# Telegram 客户端约 5-6 秒后会隐藏 typing需要周期性续发。
stop_event.wait(self._typing_interval_seconds)
finally:
with self._typing_lock:
current = self._typing_tasks.get(chat_id_str)
if current is threading.current_thread():
self._typing_tasks.pop(chat_id_str, None)
self._typing_stop_flags.pop(chat_id_str, None)
thread = threading.Thread(target=typing_worker, daemon=True)
with self._typing_lock:
self._typing_stop_flags[chat_id_str] = stop_event
self._typing_tasks[chat_id_str] = thread
thread.start()
self._typing_tasks[chat_id_str] = thread
def _stop_typing_task(self, chat_id: Union[str, int]) -> None:
"""
停止正在输入状态的任务
"""
chat_id_str = str(chat_id)
self._typing_stop_flags[chat_id_str] = True
if chat_id_str in self._typing_tasks:
with self._typing_lock:
stop_event = self._typing_stop_flags.pop(chat_id_str, None)
task = self._typing_tasks.pop(chat_id_str, None)
if task and task.is_alive():
task.join(timeout=1)
if stop_event:
stop_event.set()
if task and task.is_alive() and task is not threading.current_thread():
task.join(timeout=1)
def stop_typing(
self,
chat_id: Optional[Union[str, int]] = None,
userid: Optional[Union[str, int]] = None,
) -> bool:
"""
外部链路主动停止 typing 状态。
"""
if chat_id:
target_chat_id = chat_id
elif userid:
target_chat_id = self._get_user_chat_id(str(userid)) or str(userid)
else:
target_chat_id = None
target_chat_id = target_chat_id or (str(userid) if userid else None)
if not target_chat_id:
return False
self._stop_typing_task(target_chat_id)
return True
def send_msg(
self,
@@ -395,12 +470,12 @@ class Telegram:
if not self._telegram_token or not self._telegram_chat_id:
return None
if not title and not text:
logger.warn("标题和内容不能同时为空")
return {"success": False}
# Determine target chat_id with improved logic using user mapping
chat_id = self._determine_target_chat_id(userid, original_chat_id)
if not title and not text:
logger.warn("标题和内容不能同时为空")
self._stop_typing_task(chat_id)
return {"success": False}
try:
# 标准化标题后再加粗,避免**符号被显示为文本
@@ -483,6 +558,7 @@ class Telegram:
voice_file = Path(voice_path)
if not voice_file.exists():
logger.error(f"语音文件不存在: {voice_file}")
self._stop_typing_task(chat_id)
return {"success": False}
try:
@@ -526,12 +602,13 @@ class Telegram:
if not self._bot or not file_path:
return None
chat_id = self._determine_target_chat_id(userid, original_chat_id)
local_file = Path(file_path)
if not local_file.exists() or not local_file.is_file():
logger.error(f"附件文件不存在: {local_file}")
self._stop_typing_task(chat_id)
return {"success": False}
chat_id = self._determine_target_chat_id(userid, original_chat_id)
send_name = file_name or local_file.name
suffix = local_file.suffix.lower()
is_image = suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
@@ -622,6 +699,8 @@ class Telegram:
if not self._telegram_token or not self._telegram_chat_id:
return None
# 列表消息也可能是一次交互的最终响应,需要确保 typing 状态在发送后结束。
chat_id = self._determine_target_chat_id(userid, original_chat_id)
try:
index, image, caption = 1, "", "*%s*" % title
for media in medias:
@@ -649,9 +728,6 @@ class Telegram:
if link:
caption = f"{caption}\n[查看详情]({link})"
# Determine target chat_id with improved logic using user mapping
chat_id = self._determine_target_chat_id(userid, original_chat_id)
# 创建按钮键盘
reply_markup = None
if buttons:
@@ -675,6 +751,8 @@ class Telegram:
except Exception as msg_e:
logger.error(f"发送消息失败:{msg_e}")
return False
finally:
self._stop_typing_task(chat_id)
def send_torrents_msg(
self,
@@ -699,6 +777,8 @@ class Telegram:
if not self._telegram_token or not self._telegram_chat_id:
return None
# 资源列表是搜索交互的常见出口,也必须统一释放 typing 状态。
chat_id = self._determine_target_chat_id(userid, original_chat_id)
try:
index, caption = 1, "*%s*" % title
image = torrents[0].media_info.get_message_image()
@@ -725,9 +805,6 @@ class Telegram:
if link:
caption = f"{caption}\n[查看详情]({link})"
# Determine target chat_id with improved logic using user mapping
chat_id = self._determine_target_chat_id(userid, original_chat_id)
# 创建按钮键盘
reply_markup = None
if buttons:
@@ -751,6 +828,8 @@ class Telegram:
except Exception as msg_e:
logger.error(f"发送消息失败:{msg_e}")
return False
finally:
self._stop_typing_task(chat_id)
@staticmethod
def _create_inline_keyboard(buttons: List[List[Dict]]) -> InlineKeyboardMarkup:
@@ -872,6 +951,8 @@ class Telegram:
except Exception as e:
logger.error(f"编辑Telegram消息异常: {str(e)}")
return False
finally:
self._stop_typing_task(chat_id)
def __edit_message(
self,