fix: isolate telegram typing lifecycle

This commit is contained in:
jxxghp
2026-08-24 08:51:49 +08:00
parent 01d29d952f
commit ecbd39a0bb
4 changed files with 647 additions and 498 deletions
+101 -67
View File
@@ -77,18 +77,13 @@ class Telegram:
_bot: TeleBot = None
_callback_handlers: Dict[str, Callable] = {} # 存储回调处理器
_user_chat_mapping: Dict[
str, str
] = {} # 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, threading.Event] = {} # chat_id -> 停止信号
_typing_lock = threading.RLock()
_typing_interval_seconds = 5
_typing_initial_delay_seconds = 1
_typing_max_duration_seconds = 10 * 60
_typing_command_max_duration_seconds = 30
_typing_callback_max_duration_seconds = 60
_typing_join_timeout_seconds = 1
def __init__(
self,
@@ -103,6 +98,13 @@ class Telegram:
self._telegram_token = TELEGRAM_TOKEN
self._telegram_chat_id = TELEGRAM_CHAT_ID
self._polling_thread = None
# 一个 Telegram 配置对应一个 SDK client,运行状态不能被其他配置共享。
self._user_chat_mapping: Dict[str, str] = {}
self._typing_tasks: Dict[str, threading.Thread] = {}
self._typing_stop_flags: Dict[str, threading.Event] = {}
self._typing_lock = threading.RLock()
self._typing_lifecycle_lock = threading.RLock()
self._typing_accepting = True
if not TELEGRAM_TOKEN or not TELEGRAM_CHAT_ID:
logger.error("Telegram配置不完整!")
return
@@ -482,70 +484,101 @@ class Telegram:
chat_id: Union[str, int],
max_duration_seconds: Optional[float] = None,
initial_delay_seconds: Optional[float] = None,
) -> None:
) -> bool:
"""
启动持续发送正在输入状态的任务
启动持续发送正在输入状态的任务
:return: 是否取得该会话的唯一 typing owner
"""
chat_id_str = str(chat_id)
# 如果已有任务在运行,先停止
self._stop_typing_task(chat_id_str)
with self._typing_lifecycle_lock:
if not self._typing_accepting:
logger.debug("Telegram client已停止,拒绝启动typing任务")
return False
# 如果已有任务在运行,先停止;阻塞的旧 SDK 请求不能被新 owner 覆盖。
if not self._stop_typing_task(chat_id_str):
logger.warning(
"Telegram typing旧任务尚未结束,拒绝并行启动: chat_id=%s",
chat_id_str,
)
return False
# 使用独立 Event 避免同一 chat 新旧 typing 线程互相误改停止标记。
stop_event = threading.Event()
max_duration = max_duration_seconds or self._typing_max_duration_seconds
initial_delay = (
self._typing_initial_delay_seconds
if initial_delay_seconds is None
else max(initial_delay_seconds, 0)
)
# 使用独立 Event 避免同一 chat 新旧 typing 线程互相误改停止标记。
stop_event = threading.Event()
max_duration = max_duration_seconds or self._typing_max_duration_seconds
initial_delay = (
self._typing_initial_delay_seconds
if initial_delay_seconds is None
else max(initial_delay_seconds, 0)
)
def typing_worker():
"""延迟首发并定期发送 typing 状态的后台线程。"""
started_at = time.monotonic()
try:
# Telegram 没有撤销 typing 的接口,短响应先等待一小段时间,
# 避免回复已经发出后客户端仍残留几秒“正在输入”。
if initial_delay and stop_event.wait(initial_delay):
return
while not stop_event.is_set():
if time.monotonic() - started_at >= max_duration:
logger.warning(
"Telegram typing状态超过最大续期,自动停止: chat_id=%s",
chat_id_str,
)
break
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:
def typing_worker():
"""延迟首发并定期发送 typing 状态的后台线程。"""
started_at = time.monotonic()
try:
# Telegram 没有撤销 typing 的接口,短响应先等待一小段时间,
# 避免回复已经发出后客户端仍残留几秒“正在输入”。
if initial_delay and stop_event.wait(initial_delay):
return
while not stop_event.is_set():
if time.monotonic() - started_at >= max_duration:
logger.warning(
"Telegram typing状态超过最大续期,自动停止: chat_id=%s",
chat_id_str,
)
break
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,
name=f"MoviePilot-TelegramTyping-{chat_id_str}"[:120],
daemon=True,
)
with self._typing_lock:
self._typing_stop_flags[chat_id_str] = stop_event
self._typing_tasks[chat_id_str] = thread
try:
thread.start()
except BaseException:
self._typing_stop_flags.pop(chat_id_str, None)
self._typing_tasks.pop(chat_id_str, None)
raise
return True
def _stop_typing_task(self, chat_id: Union[str, int]) -> bool:
"""
停止正在输入状态的任务,并保留尚未结束的 owner。
:return: 任务是否已经进入终态
"""
chat_id_str = str(chat_id)
with self._typing_lifecycle_lock:
with self._typing_lock:
stop_event = self._typing_stop_flags.get(chat_id_str)
task = self._typing_tasks.get(chat_id_str)
if stop_event:
stop_event.set()
if task and task.is_alive() and task is not threading.current_thread():
task.join(timeout=self._typing_join_timeout_seconds)
task_finished = task is None or not task.is_alive()
if task_finished:
with self._typing_lock:
current = self._typing_tasks.get(chat_id_str)
if current is threading.current_thread():
if self._typing_tasks.get(chat_id_str) is task:
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()
def _stop_typing_task(self, chat_id: Union[str, int]) -> None:
"""
停止正在输入状态的任务
"""
chat_id_str = str(chat_id)
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 stop_event:
stop_event.set()
if task and task.is_alive() and task is not threading.current_thread():
task.join(timeout=1)
return task_finished
def _stop_typing_if_needed(
self, chat_id: Union[str, int], stop_typing: bool
@@ -574,8 +607,7 @@ class Telegram:
target_chat_id = target_chat_id or (str(userid) if userid else None)
if not target_chat_id:
return False
self._start_typing_task(target_chat_id)
return True
return self._start_typing_task(target_chat_id)
def stop_typing(
self,
@@ -1714,9 +1746,11 @@ class Telegram:
"""
停止Telegram消息接收服务
"""
# 停止所有typing任务
for chat_id in list(self._typing_tasks.keys()):
self._stop_typing_task(chat_id)
with self._typing_lifecycle_lock:
self._typing_accepting = False
# 封口与 owner 快照处于同一临界区,停止后不会漏掉并发新增任务。
for chat_id in list(self._typing_tasks.keys()):
self._stop_typing_task(chat_id)
if not self._bot:
return