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
+51 -377
View File
@@ -7,9 +7,6 @@ QQ群日常分析插件
import asyncio import asyncio
import os import os
import re
from collections import Counter
from datetime import datetime, timezone
from astrbot.api import AstrBotConfig, logger from astrbot.api import AstrBotConfig, logger
from astrbot.api.event import AstrMessageEvent, filter from astrbot.api.event import AstrMessageEvent, filter
@@ -33,12 +30,17 @@ from .src.infrastructure.scheduler.auto_scheduler import AutoScheduler
from .src.infrastructure.scheduler.retry import RetryManager from .src.infrastructure.scheduler.retry import RetryManager
from .src.utils.pdf_utils import PDFInstaller from .src.utils.pdf_utils import PDFInstaller
from .src.infrastructure.persistence.telegram_group_registry import (
TelegramGroupRegistry,
)
from .src.application.services.message_processing_service import (
MessageProcessingService,
)
class QQGroupDailyAnalysis(Star): class QQGroupDailyAnalysis(Star):
"""QQ群日常分析插件主类""" """QQ群日常分析插件主类"""
_TG_GROUP_REGISTRY_KV_KEY = "telegram_seen_groups_v1"
def __init__(self, context: Context, config: AstrBotConfig): def __init__(self, context: Context, config: AstrBotConfig):
super().__init__(context) super().__init__(context)
self.config = config self.config = config
@@ -47,9 +49,13 @@ class QQGroupDailyAnalysis(Star):
self.config_manager = ConfigManager(config) self.config_manager = ConfigManager(config)
self.bot_manager = BotManager(self.config_manager) self.bot_manager = BotManager(self.config_manager)
self.bot_manager.set_context(context) self.bot_manager.set_context(context)
self.bot_manager.set_plugin_instance(self)
self.history_manager = HistoryManager(self) self.history_manager = HistoryManager(self)
self.report_generator = ReportGenerator(self.config_manager) self.report_generator = ReportGenerator(self.config_manager)
# Telegram 注册表 (持久层)
self.telegram_group_registry = TelegramGroupRegistry(self)
# 2. 领域层 # 2. 领域层
self.statistics_service = StatisticsService() self.statistics_service = StatisticsService()
self.analysis_domain_service = AnalysisDomainService() self.analysis_domain_service = AnalysisDomainService()
@@ -74,6 +80,11 @@ class QQGroupDailyAnalysis(Star):
incremental_merge_service=self.incremental_merge_service, incremental_merge_service=self.incremental_merge_service,
) )
# 消息处理服务
self.message_processing_service = MessageProcessingService(
context, self.telegram_group_registry
)
# 调度与重试 # 调度与重试
self.retry_manager = RetryManager( self.retry_manager = RetryManager(
self.bot_manager, self.html_render, self.report_generator self.bot_manager, self.html_render, self.report_generator
@@ -89,7 +100,6 @@ class QQGroupDailyAnalysis(Star):
) )
self._initialized = False self._initialized = False
self._tg_registry_lock: asyncio.Lock | None = None
# 异步注册任务,处理插件重载情况 # 异步注册任务,处理插件重载情况
asyncio.create_task(self._run_initialization("Plugin Reload/Init")) asyncio.create_task(self._run_initialization("Plugin Reload/Init"))
@@ -110,7 +120,7 @@ class QQGroupDailyAnalysis(Star):
def _resolve_template_preview_path(self, template_name: str) -> str | None: def _resolve_template_preview_path(self, template_name: str) -> str | None:
"""解析模板预览图路径(兼容新旧命名和目录)""" """解析模板预览图路径(兼容新旧命名和目录)"""
plugin_root = os.path.dirname(__file__) plugin_root = os.path.dirname(__file__)
template_base_dir = self._resolve_template_base_dir()
candidate_paths = [ candidate_paths = [
os.path.join(plugin_root, "assets", f"{template_name}-demo.jpg"), os.path.join(plugin_root, "assets", f"{template_name}-demo.jpg"),
] ]
@@ -188,380 +198,14 @@ class QQGroupDailyAnalysis(Star):
self.bot_manager = None self.bot_manager = None
self.report_generator = None self.report_generator = None
self.config_manager = None self.config_manager = None
self.message_processing_service = None
self.telegram_group_registry = None
logger.info("QQ群日常分析插件资源清理完成") logger.info("QQ群日常分析插件资源清理完成")
except Exception as e: except Exception as e:
logger.error(f"插件资源清理失败: {e}") logger.error(f"插件资源清理失败: {e}")
# ==================== 消息历史存储(统一方法,可复用) ====================
async def _store_message_to_history(self, event: AstrMessageEvent) -> None:
"""
将消息存储到 AstrBot 的 message_history_manager
这是一个可复用的统一方法,支持所有通过 context 机制存储消息的平台。
不使用 fallback 值 - 如果获取不到必要数据会抛出异常。
Args:
event: AstrBot 消息事件
Raises:
ValueError: 当必要数据(group_id, sender_id, platform_id)无法获取时
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:
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._upsert_telegram_group_registry(
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})"
)
@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")
def _get_tg_registry_lock(self) -> asyncio.Lock:
"""懒加载 Telegram 群/话题注册表锁,避免并发读改写覆盖。"""
if self._tg_registry_lock is None:
self._tg_registry_lock = asyncio.Lock()
return self._tg_registry_lock
async def _upsert_telegram_group_registry(
self,
platform_id: str,
group_id: str,
sender_id: str,
sender_name: str,
event_message_id: str,
) -> None:
"""更新 Telegram 已见群/话题注册表(KV)。"""
lock = self._get_tg_registry_lock()
async with lock:
registry = await self.get_kv_data(self._TG_GROUP_REGISTRY_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.put_kv_data(self._TG_GROUP_REGISTRY_KV_KEY, registry)
async def get_telegram_seen_group_ids(
self, platform_id: str | None = None
) -> list[str]:
"""读取 Telegram 已见群/话题列表(给调度器回退使用)。"""
lock = self._get_tg_registry_lock()
async with lock:
registry = await self.get_kv_data(self._TG_GROUP_REGISTRY_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)
@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()
def _resolve_sender_name(self, event: AstrMessageEvent, sender_id: str) -> str:
"""
解析发送者展示名。
优先级:
- Telegram:
1. raw_message.from_user.full_name
2. raw_message.from_user.first_name
3. event.get_sender_name() / message_obj.sender.nickname
4. raw_message.from_user.username
5. sender_id
- 其他平台:
1. event.get_sender_name()
2. message_obj.sender.nickname
3. raw_message.from_user.full_name / first_name / username
4. sender_id(最终回退,避免消息丢失)
"""
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)
# Telegram 特殊策略:优先显示名,不优先 username
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
logger.warning(
f"[HistoryStore] 无法解析昵称,回退为 sender_id: {sender_id} "
f"(platform={event.get_platform_id()})"
)
return sender_id
def _extract_message_parts(self, event: AstrMessageEvent) -> list[dict]:
"""
从事件中提取消息内容
Returns:
消息部分列表,格式为 [{"type": "plain", "text": "..."}, ...]
"""
message_parts = []
message = event.message_obj
# 先收集 @ 标记,后续用于从 plain 文本中去重
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)
if target is None:
target = 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)
if url is None and hasattr(seg, "data"):
url = seg.data.get("url")
if url:
message_parts.append({"type": "image", "url": url})
elif seg_type in ("At", "at"):
target = getattr(seg, "target", None)
if target is None:
target = 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 ""),
}
)
# 如果没有从消息链提取到内容,尝试使用 message_str
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:
"""
从文本中移除已识别的 @ 提及,避免与结构化 at 段重复。
"""
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)
cleaned = re.sub(r"\s{2,}", " ", cleaned).strip()
return cleaned
# ==================== Telegram 消息拦截器 ==================== # ==================== Telegram 消息拦截器 ====================
@filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE) @filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE)
@@ -570,15 +214,45 @@ class QQGroupDailyAnalysis(Star):
""" """
拦截 Telegram 群消息并存储到数据库 拦截 Telegram 群消息并存储到数据库
使用统一的 _store_message_to_history 方法存储消息。 委托给 MessageProcessingService 处理
""" """
try: try:
await self._store_message_to_history(event) await self.message_processing_service.process_message(event)
except (ValueError, RuntimeError) as e: except (ValueError, RuntimeError) as e:
logger.warning(f"[Telegram] 消息存储失败: {e}") logger.warning(f"[Telegram] 消息存储失败: {e}")
except Exception as e: except Exception as e:
logger.error(f"[Telegram] 消息存储异常: {e}", exc_info=True) logger.error(f"[Telegram] 消息存储异常: {e}", exc_info=True)
async def get_telegram_seen_group_ids(
self, platform_id: str | None = None
) -> list[str]:
"""读取 Telegram 已见群/话题列表(给调度器回退使用)。"""
return await self.telegram_group_registry.get_all_group_ids(platform_id)
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
"""从消息事件中安全获取群组 ID"""
# 保留此辅助方法,因为在其他 command 中仍被频繁使用
try:
group_id = event.get_group_id()
return group_id if group_id else None
except Exception:
return None
def _get_platform_id_from_event(self, event: AstrMessageEvent) -> str:
"""从消息事件中获取平台唯一 ID"""
# 保留此辅助方法,因为在其他 command 中仍被频繁使用
try:
return event.get_platform_id()
except Exception:
# 后备方案:从元数据获取
if (
hasattr(event, "platform_meta")
and event.platform_meta
and hasattr(event.platform_meta, "id")
):
return event.platform_meta.id
return "default"
@filter.command("群分析", alias={"group_analysis"}) @filter.command("群分析", alias={"group_analysis"})
@filter.permission_type(PermissionType.ADMIN) @filter.permission_type(PermissionType.ADMIN)
async def analyze_group_daily( async def analyze_group_daily(
@@ -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: if config:
ids = config.get("bot_self_ids", []) ids = config.get("bot_self_ids", [])
self.bot_self_ids = [str(i) for i in ids] if ids else [] 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 "" self._platform_id = str(config.get("platform_id", "")).strip() if config else ""
def set_context(self, context: "Context") -> None: def set_context(self, context: "Context") -> None:
@@ -75,6 +78,46 @@ class TelegramAdapter(PlatformAdapter):
""" """
self._context = context 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 @property
def _telegram_client(self) -> "ExtBot | None": def _telegram_client(self) -> "ExtBot | None":
""" """
@@ -621,15 +664,6 @@ class TelegramAdapter(PlatformAdapter):
logger.debug(f"[Telegram] 获取群信息失败: {e}") logger.debug(f"[Telegram] 获取群信息失败: {e}")
return None 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]: async def get_member_list(self, group_id: str) -> list[UnifiedMember]:
""" """
获取成员列表 获取成员列表
@@ -26,6 +26,7 @@ class BotManager:
self._context = None self._context = None
self._is_initialized = False self._is_initialized = False
self._default_platform = "default" # 默认平台 self._default_platform = "default" # 默认平台
self._plugin_instance = None # 插件实例引用,用于适配器回调
def set_context(self, context): def set_context(self, context):
"""设置AstrBot上下文,并传递给所有支持的适配器""" """设置AstrBot上下文,并传递给所有支持的适配器"""
@@ -36,6 +37,10 @@ class BotManager:
if hasattr(adapter, "set_context"): if hasattr(adapter, "set_context"):
adapter.set_context(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): def set_bot_instance(self, bot_instance, platform_id=None, platform_name=None):
""" """
设置bot实例,支持指定平台ID 设置bot实例,支持指定平台ID
@@ -56,6 +61,7 @@ class BotManager:
adapter_config = { adapter_config = {
"bot_self_ids": self._bot_self_ids.copy(), "bot_self_ids": self._bot_self_ids.copy(),
"platform_id": str(platform_id), "platform_id": str(platform_id),
"plugin_instance": self._plugin_instance,
} }
adapter = PlatformAdapterFactory.create( adapter = PlatformAdapterFactory.create(
platform_name, bot_instance, adapter_config platform_name, bot_instance, adapter_config
+2 -37
View File
@@ -840,33 +840,19 @@ class AutoScheduler:
if str(group_id).strip() if str(group_id).strip()
] ]
# 获取平台名称(用于 Telegram 回退判定 # 获取平台名称(用于日志
p_name = None p_name = None
if hasattr(adapter, "get_platform_name"): if hasattr(adapter, "get_platform_name"):
try: try:
p_name = adapter.get_platform_name() p_name = adapter.get_platform_name()
except Exception: except Exception:
p_name = None 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: for group_id in groups:
all_groups.add((platform_id, str(group_id))) all_groups.add((platform_id, str(group_id)))
logger.info( logger.info(
f"平台 {platform_id} ({p_name}) 成功获取 {len(groups)} 个群组" f"平台 {platform_id} ({p_name or 'unknown'}) 成功获取 {len(groups)} 个群组"
+ (" (KV回退)" if used_tg_kv_fallback else "")
) )
continue continue
@@ -880,24 +866,3 @@ class AutoScheduler:
logger.error(f"平台 {platform_id} 获取群列表异常: {e}") logger.error(f"平台 {platform_id} 获取群列表异常: {e}")
return list(all_groups) 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 []