mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
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:
@@ -8,6 +8,7 @@ from datetime import datetime
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from langchain.agents import create_agent
|
from langchain.agents import create_agent
|
||||||
from langchain.agents.middleware import (
|
from langchain.agents.middleware import (
|
||||||
SummarizationMiddleware,
|
SummarizationMiddleware,
|
||||||
@@ -53,6 +54,40 @@ class AgentChain(ChainBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_processing_status(status: Optional[dict], user_id: Optional[str] = None) -> None:
|
||||||
|
"""结束入站消息的渠道处理状态。"""
|
||||||
|
if not status:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
channel = MessageChannel(status.get("channel"))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
AgentChain().run_module(
|
||||||
|
"mark_message_processing_finished",
|
||||||
|
channel=channel,
|
||||||
|
source=status.get("source"),
|
||||||
|
userid=status.get("userid") or user_id,
|
||||||
|
message_id=status.get("message_id"),
|
||||||
|
chat_id=status.get("chat_id"),
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug(f"结束Agent消息处理状态失败: {err}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_finish_processing_status(
|
||||||
|
status: Optional[dict], user_id: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
在 Agent worker 中结束渠道处理状态。
|
||||||
|
渠道收口可能触发外部 API,同步实现需切到线程池避免阻塞事件循环。
|
||||||
|
"""
|
||||||
|
if not status:
|
||||||
|
return
|
||||||
|
await run_in_threadpool(_finish_processing_status, status, user_id)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _SessionUsageSnapshot:
|
class _SessionUsageSnapshot:
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
@@ -901,6 +936,7 @@ class _MessageTask:
|
|||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
original_message_id: Optional[str] = None
|
original_message_id: Optional[str] = None
|
||||||
original_chat_id: Optional[str] = None
|
original_chat_id: Optional[str] = None
|
||||||
|
processing_status: Optional[dict] = None
|
||||||
reply_mode: ReplyMode = ReplyMode.DISPATCH
|
reply_mode: ReplyMode = ReplyMode.DISPATCH
|
||||||
|
|
||||||
|
|
||||||
@@ -987,6 +1023,7 @@ class AgentManager:
|
|||||||
username: str = None,
|
username: str = None,
|
||||||
original_message_id: Optional[str] = None,
|
original_message_id: Optional[str] = None,
|
||||||
original_chat_id: Optional[str] = None,
|
original_chat_id: Optional[str] = None,
|
||||||
|
processing_status: Optional[dict] = None,
|
||||||
reply_mode: ReplyMode = ReplyMode.DISPATCH,
|
reply_mode: ReplyMode = ReplyMode.DISPATCH,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -1004,6 +1041,7 @@ class AgentManager:
|
|||||||
username=username,
|
username=username,
|
||||||
original_message_id=original_message_id,
|
original_message_id=original_message_id,
|
||||||
original_chat_id=original_chat_id,
|
original_chat_id=original_chat_id,
|
||||||
|
processing_status=processing_status,
|
||||||
reply_mode=reply_mode,
|
reply_mode=reply_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1062,6 +1100,9 @@ class AgentManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"处理会话 {session_id} 的消息失败: {e}")
|
logger.error(f"处理会话 {session_id} 的消息失败: {e}")
|
||||||
finally:
|
finally:
|
||||||
|
await _async_finish_processing_status(
|
||||||
|
task.processing_status, task.user_id
|
||||||
|
)
|
||||||
queue.task_done()
|
queue.task_done()
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|||||||
+128
-50
@@ -31,7 +31,7 @@ from app.helper.interaction import agent_interaction_manager, media_interaction_
|
|||||||
from app.helper.torrent import TorrentHelper
|
from app.helper.torrent import TorrentHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import Notification, CommingMessage, NotExistMediaInfo
|
from app.schemas import Notification, CommingMessage, NotExistMediaInfo
|
||||||
from app.schemas.message import ChannelCapabilityManager
|
from app.schemas.message import ChannelCapabilityManager, ChannelCapability
|
||||||
from app.schemas.types import EventType, MessageChannel, MediaType
|
from app.schemas.types import EventType, MessageChannel, MediaType
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
@@ -48,11 +48,24 @@ class MessageChain(ChainBase):
|
|||||||
_session_timeout_minutes: int = 24 * 60
|
_session_timeout_minutes: int = 24 * 60
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _ProcessingMarker:
|
class _ProcessingStatus:
|
||||||
channel: MessageChannel
|
channel: MessageChannel
|
||||||
source: str
|
source: str
|
||||||
message_id: str
|
userid: Optional[Union[str, int]] = None
|
||||||
reaction_id: str
|
message_id: Optional[Union[str, int]] = None
|
||||||
|
chat_id: Optional[Union[str, int]] = None
|
||||||
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""转换为模块接口可安全传递的普通字典。"""
|
||||||
|
return {
|
||||||
|
"channel": self.channel.value,
|
||||||
|
"source": self.source,
|
||||||
|
"userid": self.userid,
|
||||||
|
"message_id": self.message_id,
|
||||||
|
"chat_id": self.chat_id,
|
||||||
|
"metadata": self.metadata or {},
|
||||||
|
}
|
||||||
|
|
||||||
def process(self, body: Any, form: Any, args: Any) -> None:
|
def process(self, body: Any, form: Any, args: Any) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -158,25 +171,40 @@ class MessageChain(ChainBase):
|
|||||||
text=text,
|
text=text,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._mark_message_processing_started(
|
processing_status = self._mark_message_processing_started(
|
||||||
channel=channel,
|
|
||||||
source=source,
|
|
||||||
original_message_id=original_message_id,
|
|
||||||
text=text,
|
|
||||||
)
|
|
||||||
self._handle_message_core(
|
|
||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
|
||||||
text=text,
|
|
||||||
original_message_id=original_message_id,
|
original_message_id=original_message_id,
|
||||||
original_chat_id=original_chat_id,
|
original_chat_id=original_chat_id,
|
||||||
images=images,
|
text=text,
|
||||||
audio_refs=audio_refs,
|
|
||||||
files=files,
|
|
||||||
has_audio_input=has_audio_input,
|
|
||||||
)
|
)
|
||||||
|
continues_async = False
|
||||||
|
try:
|
||||||
|
continues_async = self._handle_message_core(
|
||||||
|
channel=channel,
|
||||||
|
source=source,
|
||||||
|
userid=userid,
|
||||||
|
username=username,
|
||||||
|
text=text,
|
||||||
|
original_message_id=original_message_id,
|
||||||
|
original_chat_id=original_chat_id,
|
||||||
|
images=images,
|
||||||
|
audio_refs=audio_refs,
|
||||||
|
files=files,
|
||||||
|
has_audio_input=has_audio_input,
|
||||||
|
processing_status=processing_status,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if continues_async is not True:
|
||||||
|
self._mark_message_processing_finished(
|
||||||
|
channel=channel,
|
||||||
|
source=source,
|
||||||
|
userid=userid,
|
||||||
|
status=processing_status,
|
||||||
|
original_message_id=original_message_id,
|
||||||
|
original_chat_id=original_chat_id,
|
||||||
|
)
|
||||||
|
|
||||||
def _handle_message_core(
|
def _handle_message_core(
|
||||||
self,
|
self,
|
||||||
@@ -191,7 +219,8 @@ class MessageChain(ChainBase):
|
|||||||
audio_refs: Optional[List[str]] = None,
|
audio_refs: Optional[List[str]] = None,
|
||||||
files: Optional[List[CommingMessage.MessageAttachment]] = None,
|
files: Optional[List[CommingMessage.MessageAttachment]] = None,
|
||||||
has_audio_input: bool = False,
|
has_audio_input: bool = False,
|
||||||
) -> None:
|
processing_status: Optional[_ProcessingStatus] = None,
|
||||||
|
) -> bool:
|
||||||
"""执行实际消息路由,便于统一包裹处理中状态。"""
|
"""执行实际消息路由,便于统一包裹处理中状态。"""
|
||||||
|
|
||||||
if text.startswith("CALLBACK:"):
|
if text.startswith("CALLBACK:"):
|
||||||
@@ -211,14 +240,14 @@ class MessageChain(ChainBase):
|
|||||||
channel.value,
|
channel.value,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
|
|
||||||
if text.startswith("/") and not text.lower().startswith("/ai"):
|
if text.startswith("/") and not text.lower().startswith("/ai"):
|
||||||
self.eventmanager.send_event(
|
self.eventmanager.send_event(
|
||||||
EventType.CommandExcute,
|
EventType.CommandExcute,
|
||||||
{"cmd": text, "user": userid, "channel": channel, "source": source},
|
{"cmd": text, "user": userid, "channel": channel, "source": source},
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
|
|
||||||
latest_slash_interaction = self._get_latest_slash_interaction(userid)
|
latest_slash_interaction = self._get_latest_slash_interaction(userid)
|
||||||
if latest_slash_interaction == "sites":
|
if latest_slash_interaction == "sites":
|
||||||
@@ -229,7 +258,7 @@ class MessageChain(ChainBase):
|
|||||||
username=username,
|
username=username,
|
||||||
text=text,
|
text=text,
|
||||||
):
|
):
|
||||||
return
|
return False
|
||||||
|
|
||||||
if latest_slash_interaction == "subscribes":
|
if latest_slash_interaction == "subscribes":
|
||||||
if SubscribeChain().handle_text_interaction(
|
if SubscribeChain().handle_text_interaction(
|
||||||
@@ -239,7 +268,7 @@ class MessageChain(ChainBase):
|
|||||||
username=username,
|
username=username,
|
||||||
text=text,
|
text=text,
|
||||||
):
|
):
|
||||||
return
|
return False
|
||||||
|
|
||||||
if latest_slash_interaction == "skills":
|
if latest_slash_interaction == "skills":
|
||||||
if SkillsChain().handle_text_interaction(
|
if SkillsChain().handle_text_interaction(
|
||||||
@@ -249,7 +278,7 @@ class MessageChain(ChainBase):
|
|||||||
username=username,
|
username=username,
|
||||||
text=text,
|
text=text,
|
||||||
):
|
):
|
||||||
return
|
return False
|
||||||
|
|
||||||
if media_interaction_manager.get_by_user(userid):
|
if media_interaction_manager.get_by_user(userid):
|
||||||
if MediaInteractionChain().handle_text_interaction(
|
if MediaInteractionChain().handle_text_interaction(
|
||||||
@@ -259,10 +288,10 @@ class MessageChain(ChainBase):
|
|||||||
username=username,
|
username=username,
|
||||||
text=text,
|
text=text,
|
||||||
):
|
):
|
||||||
return
|
return False
|
||||||
|
|
||||||
if text.lower().startswith("/ai"):
|
if text.lower().startswith("/ai"):
|
||||||
self._handle_ai_message(
|
return self._handle_ai_message(
|
||||||
text=text,
|
text=text,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
@@ -272,14 +301,14 @@ class MessageChain(ChainBase):
|
|||||||
original_chat_id=original_chat_id,
|
original_chat_id=original_chat_id,
|
||||||
images=images,
|
images=images,
|
||||||
files=files,
|
files=files,
|
||||||
|
processing_status=processing_status,
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
settings.AI_AGENT_ENABLE
|
settings.AI_AGENT_ENABLE
|
||||||
and (settings.AI_AGENT_GLOBAL or images or files or has_audio_input)
|
and (settings.AI_AGENT_GLOBAL or images or files or has_audio_input)
|
||||||
):
|
):
|
||||||
self._handle_ai_message(
|
return self._handle_ai_message(
|
||||||
text=text,
|
text=text,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
@@ -289,8 +318,8 @@ class MessageChain(ChainBase):
|
|||||||
original_chat_id=original_chat_id,
|
original_chat_id=original_chat_id,
|
||||||
images=images,
|
images=images,
|
||||||
files=files,
|
files=files,
|
||||||
|
processing_status=processing_status,
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
if MediaInteractionChain().handle_text_interaction(
|
if MediaInteractionChain().handle_text_interaction(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
@@ -299,7 +328,7 @@ class MessageChain(ChainBase):
|
|||||||
username=username,
|
username=username,
|
||||||
text=text,
|
text=text,
|
||||||
):
|
):
|
||||||
return
|
return False
|
||||||
|
|
||||||
self.eventmanager.send_event(
|
self.eventmanager.send_event(
|
||||||
EventType.UserMessage,
|
EventType.UserMessage,
|
||||||
@@ -310,36 +339,81 @@ class MessageChain(ChainBase):
|
|||||||
"source": source,
|
"source": source,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
def _mark_message_processing_started(
|
def _mark_message_processing_started(
|
||||||
self,
|
self,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
source: str,
|
source: str,
|
||||||
|
userid: Union[str, int],
|
||||||
original_message_id: Optional[Union[str, int]],
|
original_message_id: Optional[Union[str, int]],
|
||||||
|
original_chat_id: Optional[Union[str, int]],
|
||||||
text: str,
|
text: str,
|
||||||
) -> Optional[_ProcessingMarker]:
|
) -> Optional[_ProcessingStatus]:
|
||||||
"""为支持的渠道标记“消息正在处理”。"""
|
"""为支持的渠道标记“消息正在处理”。"""
|
||||||
if channel != MessageChannel.Feishu:
|
if not ChannelCapabilityManager.supports_capability(
|
||||||
|
channel, ChannelCapability.PROCESSING_STATUS
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
if not original_message_id or not text or text.startswith("CALLBACK:"):
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
reaction_id = self.run_module(
|
try:
|
||||||
"add_feishu_message_reaction",
|
status = self.run_module(
|
||||||
message_id=str(original_message_id),
|
"mark_message_processing_started",
|
||||||
emoji_type="GLANCE",
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
)
|
userid=userid,
|
||||||
if not reaction_id:
|
message_id=original_message_id,
|
||||||
|
chat_id=original_chat_id,
|
||||||
|
text=text,
|
||||||
|
)
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug(f"标记消息处理状态失败: {err}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return self._ProcessingMarker(
|
if not isinstance(status, dict):
|
||||||
|
return None
|
||||||
|
metadata = status.get("metadata")
|
||||||
|
return self._ProcessingStatus(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
message_id=str(original_message_id),
|
userid=status.get("userid", userid),
|
||||||
reaction_id=str(reaction_id),
|
message_id=status.get("message_id", original_message_id),
|
||||||
|
chat_id=status.get("chat_id", original_chat_id),
|
||||||
|
metadata=metadata if isinstance(metadata, dict) else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _mark_message_processing_finished(
|
||||||
|
self,
|
||||||
|
channel: MessageChannel,
|
||||||
|
source: str,
|
||||||
|
userid: Union[str, int],
|
||||||
|
status: Optional[_ProcessingStatus] = None,
|
||||||
|
original_message_id: Optional[Union[str, int]] = None,
|
||||||
|
original_chat_id: Optional[Union[str, int]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
结束渠道侧“消息正在处理”状态。
|
||||||
|
不同渠道的表现可能是 reaction、typing 等,消息链只负责调用通用模块接口。
|
||||||
|
"""
|
||||||
|
if not status and not ChannelCapabilityManager.supports_capability(
|
||||||
|
channel, ChannelCapability.PROCESSING_STATUS
|
||||||
|
):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.run_module(
|
||||||
|
"mark_message_processing_finished",
|
||||||
|
channel=channel,
|
||||||
|
source=source,
|
||||||
|
userid=userid,
|
||||||
|
message_id=status.message_id if status else original_message_id,
|
||||||
|
chat_id=status.chat_id if status else original_chat_id,
|
||||||
|
status=status.to_dict() if status else None,
|
||||||
|
)
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug(f"结束消息处理状态失败: {err}")
|
||||||
|
|
||||||
def _handle_callback(
|
def _handle_callback(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
@@ -1063,7 +1137,8 @@ class MessageChain(ChainBase):
|
|||||||
images: Optional[List[CommingMessage.MessageImage]] = None,
|
images: Optional[List[CommingMessage.MessageImage]] = None,
|
||||||
files: Optional[List[CommingMessage.MessageAttachment]] = None,
|
files: Optional[List[CommingMessage.MessageAttachment]] = None,
|
||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
) -> None:
|
processing_status: Optional[_ProcessingStatus] = None,
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
处理AI智能体消息
|
处理AI智能体消息
|
||||||
"""
|
"""
|
||||||
@@ -1079,7 +1154,7 @@ class MessageChain(ChainBase):
|
|||||||
title="MoviePilot智能助手未启用,请在系统设置中启用",
|
title="MoviePilot智能助手未启用,请在系统设置中启用",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
|
|
||||||
images = CommingMessage.MessageImage.normalize_list(images)
|
images = CommingMessage.MessageImage.normalize_list(images)
|
||||||
|
|
||||||
@@ -1099,7 +1174,7 @@ class MessageChain(ChainBase):
|
|||||||
title="请输入您的问题或需求",
|
title="请输入您的问题或需求",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
|
|
||||||
# 生成或复用会话ID
|
# 生成或复用会话ID
|
||||||
session_id = session_id or self._get_or_create_session_id(userid)
|
session_id = session_id or self._get_or_create_session_id(userid)
|
||||||
@@ -1122,7 +1197,7 @@ class MessageChain(ChainBase):
|
|||||||
title="附件读取失败,请稍后重试",
|
title="附件读取失败,请稍后重试",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
elif images:
|
elif images:
|
||||||
image_attachments = self._build_image_attachments(images)
|
image_attachments = self._build_image_attachments(images)
|
||||||
if (
|
if (
|
||||||
@@ -1140,7 +1215,7 @@ class MessageChain(ChainBase):
|
|||||||
title="附件读取失败,请稍后重试",
|
title="附件读取失败,请稍后重试",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
all_files.extend(image_attachments)
|
all_files.extend(image_attachments)
|
||||||
images = None
|
images = None
|
||||||
|
|
||||||
@@ -1160,7 +1235,7 @@ class MessageChain(ChainBase):
|
|||||||
title="文件读取失败,请稍后重试",
|
title="文件读取失败,请稍后重试",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
|
|
||||||
# 在事件循环中处理
|
# 在事件循环中处理
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
@@ -1175,17 +1250,20 @@ class MessageChain(ChainBase):
|
|||||||
username=username,
|
username=username,
|
||||||
original_message_id=str(original_message_id) if original_message_id else None,
|
original_message_id=str(original_message_id) if original_message_id else None,
|
||||||
original_chat_id=original_chat_id,
|
original_chat_id=original_chat_id,
|
||||||
|
processing_status=processing_status.to_dict()
|
||||||
|
if processing_status
|
||||||
|
else None,
|
||||||
),
|
),
|
||||||
global_vars.loop,
|
global_vars.loop,
|
||||||
)
|
)
|
||||||
return
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"处理AI智能体消息失败: {e}")
|
logger.error(f"处理AI智能体消息失败: {e}")
|
||||||
self.messagehelper.put(
|
self.messagehelper.put(
|
||||||
f"AI智能体处理失败: {str(e)}", role="system", title="MoviePilot助手"
|
f"AI智能体处理失败: {str(e)}", role="system", title="MoviePilot助手"
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
|
|
||||||
def _transcribe_audio_refs(
|
def _transcribe_audio_refs(
|
||||||
self, audio_refs: List[str], channel: MessageChannel, source: str
|
self, audio_refs: List[str], channel: MessageChannel, source: str
|
||||||
|
|||||||
@@ -473,6 +473,69 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
return True
|
return True
|
||||||
return False
|
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]:
|
def send_direct_message(self, message: Notification) -> Optional[MessageResponse]:
|
||||||
"""
|
"""
|
||||||
直接发送消息并返回消息ID等信息
|
直接发送消息并返回消息ID等信息
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ class Discord:
|
|||||||
] = {} # userid -> chat_id mapping for reply targeting
|
] = {} # userid -> chat_id mapping for reply targeting
|
||||||
self._broadcast_channel = None
|
self._broadcast_channel = None
|
||||||
self._bot_user_id: Optional[int] = 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._register_events()
|
||||||
self._start()
|
self._start()
|
||||||
@@ -209,6 +213,9 @@ class Discord:
|
|||||||
if not self._client or not self._loop or not self._thread:
|
if not self._client or not self._loop or not self._thread:
|
||||||
return
|
return
|
||||||
try:
|
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(
|
asyncio.run_coroutine_threadsafe(self._client.close(), self._loop).result(
|
||||||
timeout=10
|
timeout=10
|
||||||
)
|
)
|
||||||
@@ -367,6 +374,125 @@ class Discord:
|
|||||||
logger.error(f"发送 Discord 种子列表失败:{err}")
|
logger.error(f"发送 Discord 种子列表失败:{err}")
|
||||||
return False
|
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(
|
def delete_msg(
|
||||||
self, message_id: Union[str, int], chat_id: Optional[str] = None
|
self, message_id: Union[str, int], chat_id: Optional[str] = None
|
||||||
) -> Optional[bool]:
|
) -> Optional[bool]:
|
||||||
|
|||||||
@@ -360,6 +360,67 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
|
|||||||
return False
|
return False
|
||||||
return client.delete_message_reaction(message_id=message_id, reaction_id=reaction_id)
|
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:
|
def finalize_message(self, response: MessageResponse) -> bool:
|
||||||
if response.channel != self._channel or not isinstance(response.metadata, dict):
|
if response.channel != self._channel or not isinstance(response.metadata, dict):
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.schemas.types import ModuleType
|
|||||||
|
|
||||||
|
|
||||||
class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
||||||
|
PROCESSING_REACTION = "eyes"
|
||||||
_AUDIO_SUFFIXES = (
|
_AUDIO_SUFFIXES = (
|
||||||
".mp3",
|
".mp3",
|
||||||
".m4a",
|
".m4a",
|
||||||
@@ -222,10 +223,14 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
|||||||
images = None
|
images = None
|
||||||
audio_refs = None
|
audio_refs = None
|
||||||
files = None
|
files = None
|
||||||
|
message_id = None
|
||||||
|
chat_id = None
|
||||||
if msg_json.get("type") == "message":
|
if msg_json.get("type") == "message":
|
||||||
userid = msg_json.get("user")
|
userid = msg_json.get("user")
|
||||||
text = msg_json.get("text")
|
text = msg_json.get("text")
|
||||||
username = msg_json.get("user")
|
username = msg_json.get("user")
|
||||||
|
message_id = msg_json.get("ts")
|
||||||
|
chat_id = msg_json.get("channel")
|
||||||
images = self._extract_images(msg_json)
|
images = self._extract_images(msg_json)
|
||||||
audio_refs = self._extract_audio_refs(msg_json)
|
audio_refs = self._extract_audio_refs(msg_json)
|
||||||
files = self._extract_files(msg_json)
|
files = self._extract_files(msg_json)
|
||||||
@@ -270,6 +275,8 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
|||||||
flags=re.IGNORECASE,
|
flags=re.IGNORECASE,
|
||||||
).strip()
|
).strip()
|
||||||
username = ""
|
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", {}))
|
images = self._extract_images(msg_json.get("event", {}))
|
||||||
audio_refs = self._extract_audio_refs(msg_json.get("event", {}))
|
audio_refs = self._extract_audio_refs(msg_json.get("event", {}))
|
||||||
files = self._extract_files(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")
|
userid = msg_json.get("user_id")
|
||||||
text = msg_json.get("command")
|
text = msg_json.get("command")
|
||||||
username = msg_json.get("user_name")
|
username = msg_json.get("user_name")
|
||||||
|
chat_id = msg_json.get("channel_id")
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -294,6 +302,8 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
|||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
text=text,
|
text=text,
|
||||||
|
message_id=message_id,
|
||||||
|
chat_id=chat_id,
|
||||||
images=images,
|
images=images,
|
||||||
audio_refs=audio_refs,
|
audio_refs=audio_refs,
|
||||||
files=files,
|
files=files,
|
||||||
@@ -589,6 +599,78 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
|||||||
return True
|
return True
|
||||||
return False
|
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]:
|
def send_direct_message(self, message: Notification) -> Optional[MessageResponse]:
|
||||||
"""
|
"""
|
||||||
直接发送消息并返回消息ID等信息
|
直接发送消息并返回消息ID等信息
|
||||||
|
|||||||
@@ -289,6 +289,40 @@ class Slack:
|
|||||||
logger.error(f"Slack文件发送失败: {err}")
|
logger.error(f"Slack文件发送失败: {err}")
|
||||||
return False, str(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,
|
def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None,
|
||||||
buttons: Optional[List[List[dict]]] = None,
|
buttons: Optional[List[List[dict]]] = None,
|
||||||
original_message_id: Optional[str] = None,
|
original_message_id: Optional[str] = None,
|
||||||
|
|||||||
@@ -596,6 +596,57 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
return True
|
return True
|
||||||
return False
|
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]:
|
def send_direct_message(self, message: Notification) -> Optional[MessageResponse]:
|
||||||
"""
|
"""
|
||||||
直接发送消息并返回消息ID等信息
|
直接发送消息并返回消息ID等信息
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ class Telegram:
|
|||||||
] = {} # userid -> chat_id mapping for reply targeting
|
] = {} # 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
|
||||||
_typing_tasks: Dict[str, threading.Thread] = {} # chat_id -> typing任务
|
_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__(
|
def __init__(
|
||||||
self,
|
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:
|
if not TELEGRAM_TOKEN or not TELEGRAM_CHAT_ID:
|
||||||
logger.error("Telegram配置不完整!")
|
logger.error("Telegram配置不完整!")
|
||||||
return
|
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:
|
if self._telegram_token and self._telegram_chat_id:
|
||||||
# telegram bot api 地址,格式:https://api.telegram.org
|
# telegram bot api 地址,格式:https://api.telegram.org
|
||||||
@@ -114,22 +119,42 @@ class Telegram:
|
|||||||
# Check if we should process this message
|
# Check if we should process this message
|
||||||
if self._should_process_message(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)
|
payload = self._serialize_update_payload(message)
|
||||||
if not payload:
|
if not payload:
|
||||||
logger.warn("Telegram消息序列化失败,跳过转发")
|
logger.warn("Telegram消息序列化失败,跳过转发")
|
||||||
|
self._stop_typing_task(message.chat.id)
|
||||||
return
|
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)
|
@_bot.callback_query_handler(func=lambda call: True)
|
||||||
def callback_query(call):
|
def callback_query(call):
|
||||||
"""
|
"""
|
||||||
处理按钮点击回调
|
处理按钮点击回调
|
||||||
"""
|
"""
|
||||||
|
chat_id = None
|
||||||
|
typing_started = False
|
||||||
try:
|
try:
|
||||||
# Update user-chat mapping for callbacks too
|
# Update user-chat mapping for callbacks too
|
||||||
|
chat_id = call.message.chat.id
|
||||||
self._update_user_chat_mapping(
|
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": {
|
||||||
"message_id": call.message.message_id,
|
"message_id": call.message.message_id,
|
||||||
"chat": {
|
"chat": {
|
||||||
"id": call.message.chat.id,
|
"id": chat_id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"data": callback_data,
|
"data": callback_data,
|
||||||
@@ -157,13 +182,24 @@ class Telegram:
|
|||||||
_bot.answer_callback_query(call.id)
|
_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:
|
except Exception as err:
|
||||||
logger.error(f"处理按钮回调失败:{str(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, "处理失败,请重试")
|
_bot.answer_callback_query(call.id, "处理失败,请重试")
|
||||||
|
|
||||||
def run_polling():
|
def run_polling():
|
||||||
@@ -326,46 +362,85 @@ class Telegram:
|
|||||||
"""
|
"""
|
||||||
return self._bot is not None
|
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)
|
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)
|
|
||||||
|
|
||||||
# 设置停止标志
|
# 使用独立 Event 避免同一 chat 新旧 typing 线程互相误改停止标记。
|
||||||
self._typing_stop_flags[chat_id_str] = False
|
stop_event = threading.Event()
|
||||||
|
max_duration = max_duration_seconds or self._typing_max_duration_seconds
|
||||||
|
|
||||||
def typing_worker():
|
def typing_worker():
|
||||||
"""定期发送typing状态的后台线程"""
|
"""定期发送typing状态的后台线程"""
|
||||||
while not self._typing_stop_flags.get(chat_id_str, True):
|
started_at = time.monotonic()
|
||||||
try:
|
try:
|
||||||
if self._bot:
|
while not stop_event.is_set():
|
||||||
self._bot.send_chat_action(chat_id, "typing")
|
if time.monotonic() - started_at >= max_duration:
|
||||||
except Exception as e:
|
logger.warning(
|
||||||
logger.debug(f"发送typing状态失败: {e}")
|
"Telegram typing状态超过最大续期,自动停止: chat_id=%s",
|
||||||
# 每5秒发送一次(Telegram客户端会在约5-6秒后消失状态)
|
chat_id_str,
|
||||||
for _ in range(50):
|
)
|
||||||
if self._typing_stop_flags.get(chat_id_str, True):
|
|
||||||
break
|
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)
|
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()
|
thread.start()
|
||||||
self._typing_tasks[chat_id_str] = thread
|
|
||||||
|
|
||||||
def _stop_typing_task(self, chat_id: Union[str, int]) -> None:
|
def _stop_typing_task(self, chat_id: Union[str, int]) -> None:
|
||||||
"""
|
"""
|
||||||
停止正在输入状态的任务
|
停止正在输入状态的任务
|
||||||
"""
|
"""
|
||||||
chat_id_str = str(chat_id)
|
chat_id_str = str(chat_id)
|
||||||
self._typing_stop_flags[chat_id_str] = True
|
with self._typing_lock:
|
||||||
if chat_id_str in self._typing_tasks:
|
stop_event = self._typing_stop_flags.pop(chat_id_str, None)
|
||||||
task = self._typing_tasks.pop(chat_id_str, None)
|
task = self._typing_tasks.pop(chat_id_str, None)
|
||||||
if task and task.is_alive():
|
if stop_event:
|
||||||
task.join(timeout=1)
|
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(
|
def send_msg(
|
||||||
self,
|
self,
|
||||||
@@ -395,12 +470,12 @@ class Telegram:
|
|||||||
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:
|
|
||||||
logger.warn("标题和内容不能同时为空")
|
|
||||||
return {"success": False}
|
|
||||||
|
|
||||||
# Determine target chat_id with improved logic using user mapping
|
# Determine target chat_id with improved logic using user mapping
|
||||||
chat_id = self._determine_target_chat_id(userid, original_chat_id)
|
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:
|
try:
|
||||||
# 标准化标题后再加粗,避免**符号被显示为文本
|
# 标准化标题后再加粗,避免**符号被显示为文本
|
||||||
@@ -483,6 +558,7 @@ class Telegram:
|
|||||||
voice_file = Path(voice_path)
|
voice_file = Path(voice_path)
|
||||||
if not voice_file.exists():
|
if not voice_file.exists():
|
||||||
logger.error(f"语音文件不存在: {voice_file}")
|
logger.error(f"语音文件不存在: {voice_file}")
|
||||||
|
self._stop_typing_task(chat_id)
|
||||||
return {"success": False}
|
return {"success": False}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -526,12 +602,13 @@ class Telegram:
|
|||||||
if not self._bot or not file_path:
|
if not self._bot or not file_path:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
chat_id = self._determine_target_chat_id(userid, original_chat_id)
|
||||||
local_file = Path(file_path)
|
local_file = Path(file_path)
|
||||||
if not local_file.exists() or not local_file.is_file():
|
if not local_file.exists() or not local_file.is_file():
|
||||||
logger.error(f"附件文件不存在: {local_file}")
|
logger.error(f"附件文件不存在: {local_file}")
|
||||||
|
self._stop_typing_task(chat_id)
|
||||||
return {"success": False}
|
return {"success": False}
|
||||||
|
|
||||||
chat_id = self._determine_target_chat_id(userid, original_chat_id)
|
|
||||||
send_name = file_name or local_file.name
|
send_name = file_name or local_file.name
|
||||||
suffix = local_file.suffix.lower()
|
suffix = local_file.suffix.lower()
|
||||||
is_image = suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
|
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:
|
if not self._telegram_token or not self._telegram_chat_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# 列表消息也可能是一次交互的最终响应,需要确保 typing 状态在发送后结束。
|
||||||
|
chat_id = self._determine_target_chat_id(userid, original_chat_id)
|
||||||
try:
|
try:
|
||||||
index, image, caption = 1, "", "*%s*" % title
|
index, image, caption = 1, "", "*%s*" % title
|
||||||
for media in medias:
|
for media in medias:
|
||||||
@@ -649,9 +728,6 @@ class Telegram:
|
|||||||
if link:
|
if link:
|
||||||
caption = f"{caption}\n[查看详情]({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
|
reply_markup = None
|
||||||
if buttons:
|
if buttons:
|
||||||
@@ -675,6 +751,8 @@ class Telegram:
|
|||||||
except Exception as msg_e:
|
except Exception as msg_e:
|
||||||
logger.error(f"发送消息失败:{msg_e}")
|
logger.error(f"发送消息失败:{msg_e}")
|
||||||
return False
|
return False
|
||||||
|
finally:
|
||||||
|
self._stop_typing_task(chat_id)
|
||||||
|
|
||||||
def send_torrents_msg(
|
def send_torrents_msg(
|
||||||
self,
|
self,
|
||||||
@@ -699,6 +777,8 @@ class Telegram:
|
|||||||
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
|
||||||
|
|
||||||
|
# 资源列表是搜索交互的常见出口,也必须统一释放 typing 状态。
|
||||||
|
chat_id = self._determine_target_chat_id(userid, original_chat_id)
|
||||||
try:
|
try:
|
||||||
index, caption = 1, "*%s*" % title
|
index, caption = 1, "*%s*" % title
|
||||||
image = torrents[0].media_info.get_message_image()
|
image = torrents[0].media_info.get_message_image()
|
||||||
@@ -725,9 +805,6 @@ class Telegram:
|
|||||||
if link:
|
if link:
|
||||||
caption = f"{caption}\n[查看详情]({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
|
reply_markup = None
|
||||||
if buttons:
|
if buttons:
|
||||||
@@ -751,6 +828,8 @@ class Telegram:
|
|||||||
except Exception as msg_e:
|
except Exception as msg_e:
|
||||||
logger.error(f"发送消息失败:{msg_e}")
|
logger.error(f"发送消息失败:{msg_e}")
|
||||||
return False
|
return False
|
||||||
|
finally:
|
||||||
|
self._stop_typing_task(chat_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _create_inline_keyboard(buttons: List[List[Dict]]) -> InlineKeyboardMarkup:
|
def _create_inline_keyboard(buttons: List[List[Dict]]) -> InlineKeyboardMarkup:
|
||||||
@@ -872,6 +951,8 @@ class Telegram:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"编辑Telegram消息异常: {str(e)}")
|
logger.error(f"编辑Telegram消息异常: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
finally:
|
||||||
|
self._stop_typing_task(chat_id)
|
||||||
|
|
||||||
def __edit_message(
|
def __edit_message(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -275,6 +275,8 @@ class ChannelCapability(Enum):
|
|||||||
LINKS = "links"
|
LINKS = "links"
|
||||||
# 支持文件发送
|
# 支持文件发送
|
||||||
FILE_SENDING = "file_sending"
|
FILE_SENDING = "file_sending"
|
||||||
|
# 支持可收口的消息处理状态提示,如 reaction 或 typing
|
||||||
|
PROCESSING_STATUS = "processing_status"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -312,6 +314,7 @@ class ChannelCapabilityManager:
|
|||||||
ChannelCapability.IMAGES,
|
ChannelCapability.IMAGES,
|
||||||
ChannelCapability.LINKS,
|
ChannelCapability.LINKS,
|
||||||
ChannelCapability.FILE_SENDING,
|
ChannelCapability.FILE_SENDING,
|
||||||
|
ChannelCapability.PROCESSING_STATUS,
|
||||||
},
|
},
|
||||||
max_buttons_per_row=4,
|
max_buttons_per_row=4,
|
||||||
max_button_rows=10,
|
max_button_rows=10,
|
||||||
@@ -339,6 +342,7 @@ class ChannelCapabilityManager:
|
|||||||
ChannelCapability.IMAGES,
|
ChannelCapability.IMAGES,
|
||||||
ChannelCapability.LINKS,
|
ChannelCapability.LINKS,
|
||||||
ChannelCapability.FILE_SENDING,
|
ChannelCapability.FILE_SENDING,
|
||||||
|
ChannelCapability.PROCESSING_STATUS,
|
||||||
},
|
},
|
||||||
max_buttons_per_row=3,
|
max_buttons_per_row=3,
|
||||||
max_button_rows=8,
|
max_button_rows=8,
|
||||||
@@ -370,6 +374,7 @@ class ChannelCapabilityManager:
|
|||||||
ChannelCapability.LINKS,
|
ChannelCapability.LINKS,
|
||||||
ChannelCapability.MENU_COMMANDS,
|
ChannelCapability.MENU_COMMANDS,
|
||||||
ChannelCapability.FILE_SENDING,
|
ChannelCapability.FILE_SENDING,
|
||||||
|
ChannelCapability.PROCESSING_STATUS,
|
||||||
},
|
},
|
||||||
max_buttons_per_row=3,
|
max_buttons_per_row=3,
|
||||||
max_button_rows=8,
|
max_button_rows=8,
|
||||||
@@ -390,6 +395,7 @@ class ChannelCapabilityManager:
|
|||||||
ChannelCapability.IMAGES,
|
ChannelCapability.IMAGES,
|
||||||
ChannelCapability.LINKS,
|
ChannelCapability.LINKS,
|
||||||
ChannelCapability.FILE_SENDING,
|
ChannelCapability.FILE_SENDING,
|
||||||
|
ChannelCapability.PROCESSING_STATUS,
|
||||||
},
|
},
|
||||||
max_buttons_per_row=5,
|
max_buttons_per_row=5,
|
||||||
max_button_rows=5,
|
max_button_rows=5,
|
||||||
|
|||||||
@@ -965,6 +965,50 @@ class TestFeishu(unittest.TestCase):
|
|||||||
self.assertEqual(reaction_id, "reaction_2")
|
self.assertEqual(reaction_id, "reaction_2")
|
||||||
self.assertTrue(deleted)
|
self.assertTrue(deleted)
|
||||||
|
|
||||||
|
def test_module_processing_status_uses_reaction_helpers(self):
|
||||||
|
module = FeishuModule()
|
||||||
|
module._channel = MessageChannel.Feishu
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
module,
|
||||||
|
"add_feishu_message_reaction",
|
||||||
|
return_value="reaction_processing",
|
||||||
|
) as add_reaction,
|
||||||
|
patch.object(
|
||||||
|
module,
|
||||||
|
"delete_feishu_message_reaction",
|
||||||
|
return_value=True,
|
||||||
|
) as delete_reaction,
|
||||||
|
):
|
||||||
|
status = module.mark_message_processing_started(
|
||||||
|
channel=MessageChannel.Feishu,
|
||||||
|
source="feishu-main",
|
||||||
|
userid="ou_x",
|
||||||
|
message_id="om_x",
|
||||||
|
chat_id="oc_x",
|
||||||
|
text="hello",
|
||||||
|
)
|
||||||
|
deleted = module.mark_message_processing_finished(
|
||||||
|
channel=MessageChannel.Feishu,
|
||||||
|
source="feishu-main",
|
||||||
|
userid="ou_x",
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
add_reaction.assert_called_once_with(
|
||||||
|
message_id="om_x",
|
||||||
|
emoji_type="GLANCE",
|
||||||
|
source="feishu-main",
|
||||||
|
)
|
||||||
|
delete_reaction.assert_called_once_with(
|
||||||
|
message_id="om_x",
|
||||||
|
reaction_id="reaction_processing",
|
||||||
|
source="feishu-main",
|
||||||
|
)
|
||||||
|
self.assertEqual(status["metadata"]["reaction_id"], "reaction_processing")
|
||||||
|
self.assertTrue(deleted)
|
||||||
|
|
||||||
def test_module_finalize_message_closes_streaming_card(self):
|
def test_module_finalize_message_closes_streaming_card(self):
|
||||||
module = FeishuModule()
|
module = FeishuModule()
|
||||||
module._channel = MessageChannel.Feishu
|
module._channel = MessageChannel.Feishu
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from app.agent import _finish_processing_status
|
||||||
|
from app.modules.discord import DiscordModule
|
||||||
|
from app.modules.slack import SlackModule
|
||||||
|
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
|
||||||
|
from app.schemas.types import MessageChannel
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessageProcessingStatus(unittest.TestCase):
|
||||||
|
def test_processing_status_capability_only_enabled_for_supported_channels(self):
|
||||||
|
supported = {
|
||||||
|
MessageChannel.Telegram,
|
||||||
|
MessageChannel.Feishu,
|
||||||
|
MessageChannel.Slack,
|
||||||
|
MessageChannel.Discord,
|
||||||
|
}
|
||||||
|
|
||||||
|
for channel in MessageChannel:
|
||||||
|
self.assertEqual(
|
||||||
|
ChannelCapabilityManager.supports_capability(
|
||||||
|
channel, ChannelCapability.PROCESSING_STATUS
|
||||||
|
),
|
||||||
|
channel in supported,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_slack_processing_status_uses_reaction(self):
|
||||||
|
module = SlackModule()
|
||||||
|
module._channel = MessageChannel.Slack
|
||||||
|
client = MagicMock()
|
||||||
|
client.add_reaction.return_value = True
|
||||||
|
client.remove_reaction.return_value = True
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
module, "get_config", return_value=SimpleNamespace(name="slack-main")
|
||||||
|
),
|
||||||
|
patch.object(module, "get_instance", return_value=client),
|
||||||
|
):
|
||||||
|
status = module.mark_message_processing_started(
|
||||||
|
channel=MessageChannel.Slack,
|
||||||
|
source="slack-main",
|
||||||
|
userid="U01",
|
||||||
|
message_id="1710000000.000100",
|
||||||
|
chat_id="C01",
|
||||||
|
text="hello",
|
||||||
|
)
|
||||||
|
removed = module.mark_message_processing_finished(
|
||||||
|
channel=MessageChannel.Slack,
|
||||||
|
source="slack-main",
|
||||||
|
userid="U01",
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
client.add_reaction.assert_called_once_with(
|
||||||
|
channel="C01",
|
||||||
|
timestamp="1710000000.000100",
|
||||||
|
emoji="eyes",
|
||||||
|
)
|
||||||
|
client.remove_reaction.assert_called_once_with(
|
||||||
|
channel="C01",
|
||||||
|
timestamp="1710000000.000100",
|
||||||
|
emoji="eyes",
|
||||||
|
)
|
||||||
|
self.assertEqual(status["metadata"]["kind"], "reaction")
|
||||||
|
self.assertTrue(removed)
|
||||||
|
|
||||||
|
def test_slack_parser_exposes_message_location_for_reaction_status(self):
|
||||||
|
module = SlackModule()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
module, "get_config", return_value=SimpleNamespace(name="slack-main")
|
||||||
|
):
|
||||||
|
message = module.message_parser(
|
||||||
|
source="slack-main",
|
||||||
|
body=json.dumps(
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"user": "U01",
|
||||||
|
"text": "hello",
|
||||||
|
"ts": "1710000000.000100",
|
||||||
|
"channel": "C01",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
form=None,
|
||||||
|
args=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(message.message_id, "1710000000.000100")
|
||||||
|
self.assertEqual(message.chat_id, "C01")
|
||||||
|
|
||||||
|
def test_discord_processing_status_starts_and_stops_typing(self):
|
||||||
|
module = DiscordModule()
|
||||||
|
module._channel = MessageChannel.Discord
|
||||||
|
client = MagicMock()
|
||||||
|
client.start_typing.return_value = True
|
||||||
|
client.stop_typing.return_value = True
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
module, "get_config", return_value=SimpleNamespace(name="discord-main")
|
||||||
|
),
|
||||||
|
patch.object(module, "get_instance", return_value=client),
|
||||||
|
):
|
||||||
|
status = module.mark_message_processing_started(
|
||||||
|
channel=MessageChannel.Discord,
|
||||||
|
source="discord-main",
|
||||||
|
userid="10001",
|
||||||
|
message_id="20002",
|
||||||
|
chat_id="30003",
|
||||||
|
text="hello",
|
||||||
|
)
|
||||||
|
finished = module.mark_message_processing_finished(
|
||||||
|
channel=MessageChannel.Discord,
|
||||||
|
source="discord-main",
|
||||||
|
userid="10001",
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
client.start_typing.assert_called_once_with(userid="10001", chat_id="30003")
|
||||||
|
client.stop_typing.assert_called_once_with(userid="10001", chat_id="30003")
|
||||||
|
self.assertEqual(status["metadata"]["kind"], "typing")
|
||||||
|
self.assertTrue(finished)
|
||||||
|
|
||||||
|
def test_agent_finish_processing_status_uses_module_interface(self):
|
||||||
|
status = {
|
||||||
|
"channel": MessageChannel.Telegram.value,
|
||||||
|
"source": "telegram-main",
|
||||||
|
"userid": "10001",
|
||||||
|
"message_id": None,
|
||||||
|
"chat_id": "-100",
|
||||||
|
"metadata": {"kind": "typing"},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("app.agent.AgentChain") as chain_cls:
|
||||||
|
_finish_processing_status(status, user_id="fallback")
|
||||||
|
|
||||||
|
chain_cls.return_value.run_module.assert_called_once_with(
|
||||||
|
"mark_message_processing_finished",
|
||||||
|
channel=MessageChannel.Telegram,
|
||||||
|
source="telegram-main",
|
||||||
|
userid="10001",
|
||||||
|
message_id=None,
|
||||||
|
chat_id="-100",
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from app.chain.message import MessageChain
|
||||||
|
from app.modules.telegram.telegram import Telegram
|
||||||
|
from app.schemas.types import MessageChannel
|
||||||
|
|
||||||
|
|
||||||
|
class TestTelegramTypingLifecycle(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._cleanup_typing_tasks()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._cleanup_typing_tasks()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cleanup_typing_tasks():
|
||||||
|
helper = Telegram.__new__(Telegram)
|
||||||
|
for chat_id in list(Telegram._typing_tasks.keys()):
|
||||||
|
helper._stop_typing_task(chat_id)
|
||||||
|
Telegram._typing_tasks.clear()
|
||||||
|
Telegram._typing_stop_flags.clear()
|
||||||
|
Telegram._user_chat_mapping.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _telegram_client() -> Telegram:
|
||||||
|
telegram = Telegram.__new__(Telegram)
|
||||||
|
telegram._bot = Mock()
|
||||||
|
telegram._telegram_token = "token"
|
||||||
|
telegram._telegram_chat_id = "default-chat"
|
||||||
|
# 缩短测试中的等待时间,不改变生产默认续发间隔。
|
||||||
|
telegram._typing_interval_seconds = 0.01
|
||||||
|
telegram._typing_max_duration_seconds = 1
|
||||||
|
return telegram
|
||||||
|
|
||||||
|
def test_start_typing_can_stop_by_chat_id(self):
|
||||||
|
telegram = self._telegram_client()
|
||||||
|
|
||||||
|
telegram._start_typing_task("chat-1", max_duration_seconds=1)
|
||||||
|
time.sleep(0.03)
|
||||||
|
|
||||||
|
self.assertIn("chat-1", Telegram._typing_tasks)
|
||||||
|
self.assertTrue(telegram._bot.send_chat_action.called)
|
||||||
|
self.assertTrue(telegram.stop_typing(chat_id="chat-1"))
|
||||||
|
self.assertNotIn("chat-1", Telegram._typing_tasks)
|
||||||
|
|
||||||
|
def test_start_typing_can_stop_by_user_mapping(self):
|
||||||
|
telegram = self._telegram_client()
|
||||||
|
Telegram._user_chat_mapping["10001"] = "chat-2"
|
||||||
|
|
||||||
|
telegram._start_typing_task("chat-2", max_duration_seconds=1)
|
||||||
|
time.sleep(0.03)
|
||||||
|
|
||||||
|
self.assertTrue(telegram.stop_typing(userid="10001"))
|
||||||
|
self.assertNotIn("chat-2", Telegram._typing_tasks)
|
||||||
|
|
||||||
|
def test_typing_task_has_max_duration_guard(self):
|
||||||
|
telegram = self._telegram_client()
|
||||||
|
|
||||||
|
telegram._start_typing_task("chat-3", max_duration_seconds=0.02)
|
||||||
|
time.sleep(0.08)
|
||||||
|
|
||||||
|
self.assertNotIn("chat-3", Telegram._typing_tasks)
|
||||||
|
|
||||||
|
def test_slash_command_stops_typing_when_message_handler_returns(self):
|
||||||
|
chain = MessageChain.__new__(MessageChain)
|
||||||
|
status = MessageChain._ProcessingStatus(
|
||||||
|
channel=MessageChannel.Telegram,
|
||||||
|
source="telegram-test",
|
||||||
|
userid="10001",
|
||||||
|
chat_id="-100",
|
||||||
|
metadata={"kind": "typing"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(chain, "_record_user_message"), patch.object(
|
||||||
|
chain, "_mark_message_processing_started", return_value=status
|
||||||
|
), patch.object(chain, "_handle_message_core"), patch.object(
|
||||||
|
chain, "_mark_message_processing_finished"
|
||||||
|
) as finish_status:
|
||||||
|
chain.handle_message(
|
||||||
|
channel=MessageChannel.Telegram,
|
||||||
|
source="telegram-test",
|
||||||
|
userid="10001",
|
||||||
|
username="tester",
|
||||||
|
text="/sites",
|
||||||
|
original_chat_id="-100",
|
||||||
|
)
|
||||||
|
|
||||||
|
finish_status.assert_called_once_with(
|
||||||
|
channel=MessageChannel.Telegram,
|
||||||
|
source="telegram-test",
|
||||||
|
userid="10001",
|
||||||
|
status=status,
|
||||||
|
original_message_id=None,
|
||||||
|
original_chat_id="-100",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_async_agent_keeps_processing_status_for_worker(self):
|
||||||
|
chain = MessageChain.__new__(MessageChain)
|
||||||
|
status = MessageChain._ProcessingStatus(
|
||||||
|
channel=MessageChannel.Telegram,
|
||||||
|
source="telegram-test",
|
||||||
|
userid="10001",
|
||||||
|
chat_id="-100",
|
||||||
|
metadata={"kind": "typing"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(chain, "_record_user_message"), patch.object(
|
||||||
|
chain, "_mark_message_processing_started", return_value=status
|
||||||
|
), patch.object(chain, "_handle_message_core", return_value=True), patch.object(
|
||||||
|
chain, "_mark_message_processing_finished"
|
||||||
|
) as finish_status:
|
||||||
|
chain.handle_message(
|
||||||
|
channel=MessageChannel.Telegram,
|
||||||
|
source="telegram-test",
|
||||||
|
userid="10001",
|
||||||
|
username="tester",
|
||||||
|
text="/ai 搜索电影",
|
||||||
|
original_chat_id="-100",
|
||||||
|
)
|
||||||
|
|
||||||
|
finish_status.assert_not_called()
|
||||||
|
|
||||||
|
def test_callback_stops_typing_when_message_handler_returns(self):
|
||||||
|
chain = MessageChain.__new__(MessageChain)
|
||||||
|
status = MessageChain._ProcessingStatus(
|
||||||
|
channel=MessageChannel.Telegram,
|
||||||
|
source="telegram-test",
|
||||||
|
userid="10001",
|
||||||
|
chat_id="-100",
|
||||||
|
metadata={"kind": "typing"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(chain, "_record_user_message"), patch.object(
|
||||||
|
chain, "_mark_message_processing_started", return_value=status
|
||||||
|
), patch.object(chain, "_handle_message_core"), patch.object(
|
||||||
|
chain, "_mark_message_processing_finished"
|
||||||
|
) as finish_status:
|
||||||
|
chain.handle_message(
|
||||||
|
channel=MessageChannel.Telegram,
|
||||||
|
source="telegram-test",
|
||||||
|
userid="10001",
|
||||||
|
username="tester",
|
||||||
|
text="CALLBACK:sites:req-1:refresh",
|
||||||
|
original_chat_id="-100",
|
||||||
|
)
|
||||||
|
|
||||||
|
finish_status.assert_called_once_with(
|
||||||
|
channel=MessageChannel.Telegram,
|
||||||
|
source="telegram-test",
|
||||||
|
userid="10001",
|
||||||
|
status=status,
|
||||||
|
original_message_id=None,
|
||||||
|
original_chat_id="-100",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_chain_finishes_processing_through_module_interface(self):
|
||||||
|
chain = MessageChain.__new__(MessageChain)
|
||||||
|
status = MessageChain._ProcessingStatus(
|
||||||
|
channel=MessageChannel.Telegram,
|
||||||
|
source="telegram-test",
|
||||||
|
userid="10001",
|
||||||
|
chat_id="-100",
|
||||||
|
metadata={"kind": "typing"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(chain, "run_module") as run_module:
|
||||||
|
chain._mark_message_processing_finished(
|
||||||
|
channel=MessageChannel.Telegram,
|
||||||
|
source="telegram-test",
|
||||||
|
userid="10001",
|
||||||
|
status=status,
|
||||||
|
original_chat_id="-100",
|
||||||
|
)
|
||||||
|
|
||||||
|
run_module.assert_called_once_with(
|
||||||
|
"mark_message_processing_finished",
|
||||||
|
channel=MessageChannel.Telegram,
|
||||||
|
source="telegram-test",
|
||||||
|
userid="10001",
|
||||||
|
message_id=None,
|
||||||
|
chat_id="-100",
|
||||||
|
status=status.to_dict(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user