refactor(Telegram): 重构 Telegram 逻辑并提取消息处理与注册表服务

- 提取消息处理服务及 Telegram 注册表,显著精简主插件逻辑。
- Telegram 群组获取回退逻辑下沉至适配器,移除调度中心硬编码。
- 优化依赖注入机制,使适配器层级可访问插件 KV 存储。
- 修正消息内容定义兼容性并清理冗余代码。
This commit is contained in:
SXP-Simon
2026-02-12 15:41:07 +08:00
committed by Helian Nuits
parent 8cc8960cb1
commit b4eec978fe
6 changed files with 473 additions and 423 deletions
@@ -0,0 +1,269 @@
import re
from collections import Counter
from astrbot.api.event import AstrMessageEvent
from astrbot.api.star import Context
from ...utils.logger import logger
from ...infrastructure.persistence.telegram_group_registry import TelegramGroupRegistry
class MessageProcessingService:
"""
消息处理服务
负责处理接收到的消息事件:
1. 解析消息内容(文本、图片、@提及等)
2. 解析发送者信息(跨平台兼容)
3. 存储消息历史
4. 维护 Telegram 群组注册表(回退机制)
"""
def __init__(self, context: Context, telegram_registry: TelegramGroupRegistry):
self.context = context
self.telegram_registry = telegram_registry
async def process_message(self, event: AstrMessageEvent) -> None:
"""
处理并在历史记录中存储消息。
Args:
event: AstrBot 消息事件
Raises:
ValueError: 当必要数据无法获取时
RuntimeError: 当消息内容为空时
"""
# 1. 获取群组 ID(必需)
group_id = self._get_group_id_from_event(event)
if not group_id:
raise ValueError("无法获取群组 ID,拒绝存储消息")
# 2. 获取发送者 ID(必需)
sender_id = event.get_sender_id()
if not sender_id:
raise ValueError(f"{group_id}: 无法获取发送者 ID,拒绝存储消息")
sender_id = str(sender_id)
# 3. 获取发送者名称(昵称优先,必要时回退)
sender_name = self._resolve_sender_name(event, sender_id)
# 4. 获取平台 ID(必需)
platform_id = event.get_platform_id()
if not platform_id:
raise ValueError(f"{group_id}: 无法获取平台 ID,拒绝存储消息")
# 5. 提取消息内容
message_parts = self._extract_message_parts(event)
if not message_parts:
# 尝试记录一条警告但不中断流程(或者视为错误)
# 原逻辑是抛出 RuntimeError
raise RuntimeError(
f"{group_id}: 消息内容为空 (sender={sender_name}),拒绝存储"
)
# 6. 提取事件消息 ID(用于 Telegram 已见群/话题记录)
msg_obj = getattr(event, "message_obj", None)
event_message_id = str(getattr(msg_obj, "message_id", "") or "")
# 7. 存储到数据库
await self.context.message_history_manager.insert(
platform_id=platform_id,
user_id=group_id,
content={"type": "user", "message": message_parts},
sender_id=sender_id,
sender_name=sender_name,
)
# Telegram: 记录已见群/话题
if self._is_telegram_event(event, platform_id):
try:
await self.telegram_registry.upsert(
platform_id=platform_id,
group_id=group_id,
sender_id=sender_id,
sender_name=sender_name,
event_message_id=event_message_id,
)
except Exception as e:
logger.warning(
"[TGRegistry] Upsert failed: "
f"platform_id={platform_id} group_id={group_id} error={e}"
)
logger.debug(
f"[{platform_id}] 已缓存群 {group_id} 的消息 (发送者: {sender_name})"
)
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
"""从消息事件中安全获取群组 ID"""
try:
group_id = event.get_group_id()
return group_id if group_id else None
except Exception:
return None
def _resolve_sender_name(self, event: AstrMessageEvent, sender_id: str) -> str:
"""解析发送者展示名"""
platform_name = str(event.get_platform_name() or "").lower()
candidates: list[str | None] = []
msg_obj = getattr(event, "message_obj", None)
sender_obj = getattr(msg_obj, "sender", None)
raw_message = getattr(msg_obj, "raw_message", None)
raw_msg_obj = getattr(raw_message, "message", raw_message)
from_user = getattr(raw_msg_obj, "from_user", None)
if platform_name == "telegram":
if from_user is not None:
candidates.extend(
[
getattr(from_user, "full_name", None),
getattr(from_user, "first_name", None),
]
)
candidates.append(event.get_sender_name())
if sender_obj is not None:
candidates.append(getattr(sender_obj, "nickname", None))
if from_user is not None:
candidates.append(getattr(from_user, "username", None))
else:
candidates.append(event.get_sender_name())
if sender_obj is not None:
candidates.append(getattr(sender_obj, "nickname", None))
if from_user is not None:
candidates.extend(
[
getattr(from_user, "full_name", None),
getattr(from_user, "first_name", None),
getattr(from_user, "username", None),
]
)
for candidate in candidates:
name = str(candidate or "").strip()
if not self._is_placeholder_sender_name(name, sender_id):
return name
return sender_id
def _extract_message_parts(self, event: AstrMessageEvent) -> list[dict]:
"""从事件中提取消息内容"""
message_parts = []
message = event.message_obj
# 收集 @ 标记
pending_mentions: Counter[str] = Counter()
if message and hasattr(message, "message"):
for seg in message.message:
if not hasattr(seg, "type"):
continue
if seg.type not in ("At", "at"):
continue
target = getattr(seg, "target", None) or getattr(seg, "qq", None)
if target is None and hasattr(seg, "data"):
target = seg.data.get("qq") or seg.data.get("target")
target_str = str(target or "").strip()
if target_str:
pending_mentions[target_str] += 1
display_name = str(getattr(seg, "name", "") or "").strip()
if display_name and display_name != target_str:
pending_mentions[display_name] += 1
if message and hasattr(message, "message"):
for seg in message.message:
if not hasattr(seg, "type"):
continue
seg_type = seg.type
if seg_type in ("Plain", "text"):
text = getattr(seg, "text", None)
if text is None and hasattr(seg, "data"):
text = seg.data.get("text")
if text:
text = self._strip_known_mentions(text, pending_mentions)
message_parts.append({"type": "plain", "text": text})
elif seg_type in ("Image", "image"):
url = getattr(seg, "url", None) or (
seg.data.get("url") if hasattr(seg, "data") else None
)
if url:
message_parts.append({"type": "image", "url": url})
elif seg_type in ("At", "at"):
target = getattr(seg, "target", None) or getattr(seg, "qq", None)
if target is None and hasattr(seg, "data"):
target = seg.data.get("qq") or seg.data.get("target")
if target:
message_parts.append(
{
"type": "at",
"target_id": str(target),
"name": str(getattr(seg, "name", "") or ""),
}
)
if not message_parts and event.message_str:
message_parts.append({"type": "plain", "text": event.message_str})
# 清理空文本段
message_parts = [
part
for part in message_parts
if not (
part.get("type") == "plain" and not str(part.get("text", "")).strip()
)
]
return message_parts
@staticmethod
def _strip_known_mentions(text: str, pending_mentions: Counter[str]) -> str:
"""从文本中移除已识别的 @ 提及"""
cleaned = str(text)
if not cleaned or not pending_mentions:
return cleaned.strip()
for mention, remaining in list(pending_mentions.items()):
if not mention or remaining <= 0:
continue
pattern = re.compile(rf"(?<!\w)@{re.escape(mention)}(?!\w)")
removed = 0
while removed < remaining:
cleaned, subn = pattern.subn("", cleaned, count=1)
if subn == 0:
break
removed += 1
if removed > 0:
pending_mentions[mention] -= removed
if pending_mentions[mention] <= 0:
pending_mentions.pop(mention, None)
return re.sub(r"\s{2,}", " ", cleaned).strip()
@staticmethod
def _is_placeholder_sender_name(name: str | None, sender_id: str) -> bool:
"""判断 sender_name 是否为占位值"""
if not name:
return True
normalized = str(name).strip()
if not normalized:
return True
if normalized.lower() in {"unknown", "none", "null", "nil", "undefined"}:
return True
return normalized == str(sender_id).strip()
@staticmethod
def _is_telegram_event(event: AstrMessageEvent, platform_id: str) -> bool:
"""判断当前事件是否为 Telegram 平台"""
platform_name = str(event.get_platform_name() or "").strip().lower()
if platform_name == "telegram":
return True
return str(platform_id or "").strip().lower().startswith("telegram")
@@ -0,0 +1,102 @@
import asyncio
from datetime import datetime, timezone
from astrbot.api.star import Star
class TelegramGroupRegistry:
"""
Telegram 群组/话题注册表
负责管理 Telegram 的已见群组和话题列表,用于在无法通过 API 获取群列表时提供回退支持。
数据存储在 AstrBot 的 KV 存储中。
"""
_KV_KEY = "telegram_seen_groups_v1"
def __init__(self, plugin_instance: Star):
self.plugin = plugin_instance
self._lock = asyncio.Lock()
async def upsert(
self,
platform_id: str,
group_id: str,
sender_id: str,
sender_name: str,
event_message_id: str,
) -> None:
"""更新 Telegram 已见群/话题注册表(KV)。"""
async with self._lock:
registry = await self.plugin.get_kv_data(self._KV_KEY, {})
if not isinstance(registry, dict):
registry = {}
platforms = registry.get("platforms")
if not isinstance(platforms, dict):
platforms = {}
registry["platforms"] = platforms
platform_key = str(platform_id).strip()
group_key = str(group_id).strip()
platform_map = platforms.get(platform_key)
if not isinstance(platform_map, dict):
platform_map = {}
platforms[platform_key] = platform_map
now_iso = datetime.now(timezone.utc).isoformat()
entry = platform_map.get(group_key)
if not isinstance(entry, dict):
entry = {}
first_seen = entry.get("first_seen")
if not isinstance(first_seen, str) or not first_seen:
first_seen = now_iso
entry.update(
{
"first_seen": first_seen,
"last_seen": now_iso,
"last_sender_id": str(sender_id),
"last_sender_name": str(sender_name),
"last_event_message_id": str(event_message_id),
}
)
platform_map[group_key] = entry
registry["updated_at"] = now_iso
await self.plugin.put_kv_data(self._KV_KEY, registry)
async def get_all_group_ids(self, platform_id: str | None = None) -> list[str]:
"""读取 Telegram 已见群/话题列表。"""
async with self._lock:
registry = await self.plugin.get_kv_data(self._KV_KEY, {})
if not isinstance(registry, dict):
return []
platforms = registry.get("platforms")
if not isinstance(platforms, dict):
return []
groups: set[str] = set()
if platform_id:
platform_map = platforms.get(str(platform_id).strip(), {})
if isinstance(platform_map, dict):
groups.update(
str(gid).strip()
for gid in platform_map.keys()
if str(gid).strip()
)
else:
for platform_map in platforms.values():
if not isinstance(platform_map, dict):
continue
groups.update(
str(gid).strip()
for gid in platform_map.keys()
if str(gid).strip()
)
return sorted(groups)
@@ -65,6 +65,9 @@ class TelegramAdapter(PlatformAdapter):
if config:
ids = config.get("bot_self_ids", [])
self.bot_self_ids = [str(i) for i in ids] if ids else []
self._plugin_instance = config.get("plugin_instance")
else:
self._plugin_instance = None
self._platform_id = str(config.get("platform_id", "")).strip() if config else ""
def set_context(self, context: "Context") -> None:
@@ -75,6 +78,46 @@ class TelegramAdapter(PlatformAdapter):
"""
self._context = context
def _init_capabilities(self) -> PlatformCapabilities:
"""返回 Telegram 平台能力声明"""
return TELEGRAM_CAPABILITIES
async def get_group_list(self) -> list[str]:
"""
获取群组列表
Telegram Bot API 不支持直接获取群列表。
因此这里尝试结合多种策略:
1. 尝试调用 API (如果未来支持)
2. 回退:从插件的 KV 存储中获取已知群组 (需注入插件实例)
"""
groups = []
client = self._telegram_client
# 1. 尝试 API (目前 python-telegram-bot 不支持直接列出所有 chat)
# 如果 client 有扩展方法或未来支持,可在此实现
# 2. 回退:使用 KV 注册表
if not groups and self._plugin_instance:
try:
# 检查插件实例是否有 get_telegram_seen_group_ids 方法
if hasattr(self._plugin_instance, "get_telegram_seen_group_ids"):
kv_groups = await self._plugin_instance.get_telegram_seen_group_ids(
self._platform_id
)
if kv_groups:
groups.extend(kv_groups)
logger.debug(
f"[Telegram] 通过 KV 回退获取到 {len(kv_groups)} 个群组"
)
except Exception as e:
logger.warning(f"[Telegram] KV 回退获取群列表失败: {e}")
if not groups:
logger.debug("[Telegram] 无法获取群列表 (API不支持且无KV记录)")
return list(set(groups))
@property
def _telegram_client(self) -> "ExtBot | None":
"""
@@ -621,15 +664,6 @@ class TelegramAdapter(PlatformAdapter):
logger.debug(f"[Telegram] 获取群信息失败: {e}")
return None
async def get_group_list(self) -> list[str]:
"""
获取群组列表
Telegram Bot API 不支持获取群列表。
"""
logger.debug("[Telegram] Bot API 不支持获取群列表")
return []
async def get_member_list(self, group_id: str) -> list[UnifiedMember]:
"""
获取成员列表
@@ -26,6 +26,7 @@ class BotManager:
self._context = None
self._is_initialized = False
self._default_platform = "default" # 默认平台
self._plugin_instance = None # 插件实例引用,用于适配器回调
def set_context(self, context):
"""设置AstrBot上下文,并传递给所有支持的适配器"""
@@ -36,6 +37,10 @@ class BotManager:
if hasattr(adapter, "set_context"):
adapter.set_context(context)
def set_plugin_instance(self, plugin_instance: Any):
"""设置插件实例引用"""
self._plugin_instance = plugin_instance
def set_bot_instance(self, bot_instance, platform_id=None, platform_name=None):
"""
设置bot实例,支持指定平台ID
@@ -56,6 +61,7 @@ class BotManager:
adapter_config = {
"bot_self_ids": self._bot_self_ids.copy(),
"platform_id": str(platform_id),
"plugin_instance": self._plugin_instance,
}
adapter = PlatformAdapterFactory.create(
platform_name, bot_instance, adapter_config
+2 -37
View File
@@ -840,33 +840,19 @@ class AutoScheduler:
if str(group_id).strip()
]
# 获取平台名称(用于 Telegram 回退判定
# 获取平台名称(用于日志
p_name = None
if hasattr(adapter, "get_platform_name"):
try:
p_name = adapter.get_platform_name()
except Exception:
p_name = None
if not p_name:
p_name = (
self.bot_manager._detect_platform_name(bot_instance)
or "unknown"
)
p_name = str(p_name).strip()
used_tg_kv_fallback = False
if not groups and p_name.lower() == "telegram":
groups = await self._get_telegram_groups_from_plugin_kv(
str(platform_id)
)
used_tg_kv_fallback = bool(groups)
for group_id in groups:
all_groups.add((platform_id, str(group_id)))
logger.info(
f"平台 {platform_id} ({p_name}) 成功获取 {len(groups)} 个群组"
+ (" (KV回退)" if used_tg_kv_fallback else "")
f"平台 {platform_id} ({p_name or 'unknown'}) 成功获取 {len(groups)} 个群组"
)
continue
@@ -880,24 +866,3 @@ class AutoScheduler:
logger.error(f"平台 {platform_id} 获取群列表异常: {e}")
return list(all_groups)
async def _get_telegram_groups_from_plugin_kv(self, platform_id: str) -> list[str]:
"""从插件 KV 获取 Telegram 已见群/话题,作为 get_group_list 的回退。"""
if not self.plugin_instance:
return []
getter = getattr(self.plugin_instance, "get_telegram_seen_group_ids", None)
if not callable(getter):
return []
try:
groups = await getter(platform_id=platform_id)
return sorted(
{str(group_id).strip() for group_id in groups if str(group_id).strip()}
)
except Exception as e:
logger.warning(
"[TGRegistry] Scheduler fetch failed: "
f"platform_id={platform_id} error={e}"
)
return []