feat: 添加 NapCat 适配器的入站消息编解码功能,增强插件配置更新逻辑和数据库交互测试
This commit is contained in:
@@ -461,25 +461,43 @@ class ExpressionLearner:
|
||||
def _find_similar_expression(
|
||||
self, situation: str, similarity_threshold: float = 0.75
|
||||
) -> Optional[Tuple[MaiExpression, float]]:
|
||||
"""在数据库中查找相似的表达方式"""
|
||||
"""在数据库中查找相似的表达方式。
|
||||
|
||||
Args:
|
||||
situation: 当前待匹配的情景描述。
|
||||
similarity_threshold: 认定为相似表达方式的最低相似度阈值。
|
||||
|
||||
Returns:
|
||||
Optional[Tuple[MaiExpression, float]]: 若找到最相似的表达方式,则返回
|
||||
``(表达方式对象, 相似度)``;否则返回 ``None``。
|
||||
"""
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
with get_db_session(auto_commit=False) as session:
|
||||
statement = select(Expression).filter_by(session_id=self.session_id)
|
||||
expressions = session.exec(statement).all()
|
||||
|
||||
best_match: Optional[Expression] = None
|
||||
best_similarity = 0.0
|
||||
best_match: Optional[MaiExpression] = None
|
||||
best_similarity = 0.0
|
||||
|
||||
for db_expression in expressions:
|
||||
expression = MaiExpression.from_db_instance(db_expression)
|
||||
candidate_situations = [expression.situation, *expression.content]
|
||||
for candidate_situation in candidate_situations:
|
||||
normalized_candidate_situation = candidate_situation.strip()
|
||||
if not normalized_candidate_situation:
|
||||
continue
|
||||
similarity = difflib.SequenceMatcher(
|
||||
None,
|
||||
situation,
|
||||
normalized_candidate_situation,
|
||||
).ratio()
|
||||
if similarity > similarity_threshold and similarity > best_similarity:
|
||||
best_similarity = similarity
|
||||
best_match = expression
|
||||
|
||||
for expr in expressions:
|
||||
content_list = json.loads(expr.content_list)
|
||||
for situation in content_list:
|
||||
similarity = difflib.SequenceMatcher(None, situation, expr.situation).ratio()
|
||||
if similarity > similarity_threshold and similarity > best_similarity:
|
||||
best_similarity = similarity
|
||||
best_match = expr
|
||||
if best_match:
|
||||
logger.debug(f"找到相似表达方式情景 [ID: {best_match.id}],相似度: {best_similarity:.2f}")
|
||||
return MaiExpression.from_db_instance(best_match), best_similarity
|
||||
logger.debug(f"找到相似表达方式情景 [ID: {best_match.item_id}],相似度: {best_similarity:.2f}")
|
||||
return best_match, best_similarity
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查找相似表达方式失败: {e}")
|
||||
|
||||
@@ -199,7 +199,7 @@ class JargonMiner:
|
||||
|
||||
async def process_extracted_entries(
|
||||
self, entries: List[JargonEntry], person_name_filter: Optional[Callable[[str], bool]] = None
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
处理已提取的黑话条目(从 expression_learner 路由过来的)
|
||||
|
||||
@@ -230,7 +230,7 @@ class JargonMiner:
|
||||
content = entry["content"]
|
||||
raw_content_set = entry["raw_content"]
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
with get_db_session(auto_commit=False) as session:
|
||||
jargon_items = session.exec(select(Jargon).filter_by(content=content)).all()
|
||||
except Exception as e:
|
||||
logger.error(f"查询黑话 '{content}' 失败: {e}")
|
||||
@@ -306,7 +306,13 @@ class JargonMiner:
|
||||
removed_content, _ = self.cache.popitem(last=False)
|
||||
logger.debug(f"缓存已满,移除最旧的黑话: {removed_content}")
|
||||
|
||||
def _update_jargon(self, db_jargon: Jargon, raw_content_set: Set[str]):
|
||||
def _update_jargon(self, db_jargon: Jargon, raw_content_set: Set[str]) -> None:
|
||||
"""更新已有黑话记录并写回数据库。
|
||||
|
||||
Args:
|
||||
db_jargon: 已命中的黑话 ORM 对象。
|
||||
raw_content_set: 本次新增的原始上下文集合。
|
||||
"""
|
||||
db_jargon.count += 1
|
||||
existing_raw_content: List[str] = []
|
||||
if db_jargon.raw_content:
|
||||
@@ -328,7 +334,17 @@ class JargonMiner:
|
||||
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
session.add(db_jargon)
|
||||
if db_jargon.id is None:
|
||||
raise ValueError("黑话记录缺少 id,无法更新数据库")
|
||||
statement = select(Jargon).filter_by(id=db_jargon.id).limit(1)
|
||||
if persisted_jargon := session.exec(statement).first():
|
||||
persisted_jargon.count = db_jargon.count
|
||||
persisted_jargon.raw_content = db_jargon.raw_content
|
||||
persisted_jargon.session_id_dict = db_jargon.session_id_dict
|
||||
persisted_jargon.is_global = db_jargon.is_global
|
||||
session.add(persisted_jargon)
|
||||
else:
|
||||
logger.warning(f"黑话 ID {db_jargon.id} 在数据库中未找到,无法更新")
|
||||
except Exception as e:
|
||||
logger.error(f"更新黑话 '{db_jargon.content}' 失败: {e}")
|
||||
|
||||
|
||||
@@ -612,7 +612,17 @@ class PluginRuntimeManager(
|
||||
return None if plugin_path is None else plugin_path / "config.toml"
|
||||
|
||||
async def _handle_plugin_config_changes(self, plugin_id: str, changes: Sequence[FileChange]) -> None:
|
||||
"""处理单个插件配置文件变化,并仅向目标插件推送配置更新。"""
|
||||
"""处理单个插件配置文件变化,并精确重载目标插件。
|
||||
|
||||
Args:
|
||||
plugin_id: 发生配置变更的插件 ID。
|
||||
changes: 当前批次收集到的配置文件变更列表。
|
||||
|
||||
Notes:
|
||||
这里选择“精确重载该插件”,而不是仅推送软性的配置更新通知。
|
||||
这样可以保证没有实现 ``on_config_update()`` 的插件也能重新执行
|
||||
``on_load()``,让磁盘上的 ``config.toml`` 修改对插件运行态真正生效。
|
||||
"""
|
||||
if not self._started or not changes:
|
||||
return
|
||||
|
||||
@@ -626,18 +636,24 @@ class PluginRuntimeManager(
|
||||
return
|
||||
|
||||
try:
|
||||
await supervisor.notify_plugin_config_updated(
|
||||
self._load_plugin_config_for_supervisor(supervisor, plugin_id)
|
||||
reload_success = await supervisor.reload_plugin(
|
||||
plugin_id=plugin_id,
|
||||
config_data=self._load_plugin_config_for_supervisor(supervisor, plugin_id),
|
||||
reason="config_file_changed",
|
||||
)
|
||||
if reload_success:
|
||||
self._refresh_plugin_config_watch_subscriptions()
|
||||
else:
|
||||
logger.warning(f"插件 {plugin_id} 配置文件变更后重载失败")
|
||||
except Exception as exc:
|
||||
logger.warning(f"插件 {plugin_id} 配置热更新通知失败: {exc}")
|
||||
logger.warning(f"插件 {plugin_id} 配置文件变更处理失败: {exc}")
|
||||
|
||||
async def _handle_plugin_source_changes(self, changes: Sequence[FileChange]) -> None:
|
||||
"""处理插件源码相关变化。
|
||||
|
||||
这里仅负责源码、清单等会影响插件装载状态的文件;配置文件的变化会由
|
||||
单独的 per-plugin watcher 处理,避免把单插件配置更新放大成全量 reload。
|
||||
单独的 per-plugin watcher 处理,并精确重载对应插件,避免放大成
|
||||
不必要的跨插件 reload。
|
||||
"""
|
||||
if not self._started or not changes:
|
||||
return
|
||||
|
||||
@@ -5,11 +5,15 @@ from uuid import uuid4
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
from napcat_adapter.qq_queries import NapCatQueryService
|
||||
|
||||
|
||||
_CQ_SEGMENT_PATTERN = re.compile(r"\[CQ:(?P<type>[a-zA-Z0-9_]+)(?P<params>(?:,[^\]]*)?)\]")
|
||||
|
||||
|
||||
class NapCatInboundCodec:
|
||||
"""NapCat 入站消息编码器。"""
|
||||
|
||||
@@ -104,8 +108,12 @@ class NapCatInboundCodec:
|
||||
"""
|
||||
message_payload = payload.get("message")
|
||||
if isinstance(message_payload, str):
|
||||
normalized_text = message_payload.strip()
|
||||
return ([{"type": "text", "data": normalized_text}] if normalized_text else []), False
|
||||
parsed_message_payload = self._parse_cq_message_text(message_payload)
|
||||
if parsed_message_payload:
|
||||
message_payload = parsed_message_payload
|
||||
else:
|
||||
normalized_text = self._decode_cq_entities(message_payload).strip()
|
||||
return ([{"type": "text", "data": normalized_text}] if normalized_text else []), False
|
||||
|
||||
if not isinstance(message_payload, list):
|
||||
return [], False
|
||||
@@ -223,8 +231,8 @@ class NapCatInboundCodec:
|
||||
Returns:
|
||||
Dict[str, Any]: 转换后的图片或表情消息段。
|
||||
"""
|
||||
subtype = segment_data.get("sub_type")
|
||||
actual_is_emoji = is_emoji or (isinstance(subtype, int) and subtype not in {0, 4, 9})
|
||||
subtype = self._normalize_numeric_segment_value(segment_data.get("sub_type"))
|
||||
actual_is_emoji = is_emoji or (subtype is not None and subtype not in {0, 4, 9})
|
||||
|
||||
image_url = str(segment_data.get("url") or "").strip()
|
||||
binary_data = await self._query_service.download_binary(image_url)
|
||||
@@ -412,3 +420,91 @@ class NapCatInboundCodec:
|
||||
|
||||
plain_text = "".join(part for part in plain_text_parts if part).strip()
|
||||
return plain_text or fallback_text or "[unsupported]"
|
||||
|
||||
def _parse_cq_message_text(self, message_text: str) -> List[Dict[str, Any]]:
|
||||
"""将 CQ 码字符串解析为 OneBot 风格消息段列表。
|
||||
|
||||
Args:
|
||||
message_text: NapCat 在字符串模式下返回的消息内容。
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 解析后的 OneBot 风格消息段列表。
|
||||
"""
|
||||
parsed_segments: List[Dict[str, Any]] = []
|
||||
current_index = 0
|
||||
|
||||
for match in _CQ_SEGMENT_PATTERN.finditer(message_text):
|
||||
prefix_text = self._decode_cq_entities(message_text[current_index : match.start()])
|
||||
if prefix_text:
|
||||
parsed_segments.append({"type": "text", "data": {"text": prefix_text}})
|
||||
|
||||
segment_type = str(match.group("type") or "").strip()
|
||||
segment_data = self._parse_cq_segment_data(match.group("params") or "")
|
||||
if segment_type:
|
||||
parsed_segments.append({"type": segment_type, "data": segment_data})
|
||||
current_index = match.end()
|
||||
|
||||
suffix_text = self._decode_cq_entities(message_text[current_index:])
|
||||
if suffix_text:
|
||||
parsed_segments.append({"type": "text", "data": {"text": suffix_text}})
|
||||
|
||||
return parsed_segments
|
||||
|
||||
def _parse_cq_segment_data(self, raw_params: str) -> Dict[str, Any]:
|
||||
"""解析单个 CQ 段中的参数串。
|
||||
|
||||
Args:
|
||||
raw_params: 形如 ``,key=value,key2=value2`` 的原始参数字符串。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 解析后的参数字典。
|
||||
"""
|
||||
parsed_data: Dict[str, Any] = {}
|
||||
if not raw_params:
|
||||
return parsed_data
|
||||
|
||||
for item in raw_params.lstrip(",").split(","):
|
||||
if not item or "=" not in item:
|
||||
continue
|
||||
key, value = item.split("=", 1)
|
||||
normalized_key = key.strip()
|
||||
if not normalized_key:
|
||||
continue
|
||||
decoded_value = self._decode_cq_entities(value)
|
||||
parsed_data[normalized_key] = self._normalize_numeric_segment_value(decoded_value)
|
||||
|
||||
return parsed_data
|
||||
|
||||
@staticmethod
|
||||
def _decode_cq_entities(text: str) -> str:
|
||||
"""解码 CQ 码中的 HTML 风格转义实体。
|
||||
|
||||
Args:
|
||||
text: 待解码的 CQ 文本。
|
||||
|
||||
Returns:
|
||||
str: 解码后的普通文本。
|
||||
"""
|
||||
return (
|
||||
text.replace("&", "&")
|
||||
.replace("[", "[")
|
||||
.replace("]", "]")
|
||||
.replace(",", ",")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_numeric_segment_value(value: Any) -> Any:
|
||||
"""将可安全识别的数字字符串转为整数。
|
||||
|
||||
Args:
|
||||
value: 原始字段值。
|
||||
|
||||
Returns:
|
||||
Any: 规范化后的字段值。
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
stripped_value = value.strip()
|
||||
if stripped_value.isdigit():
|
||||
return int(stripped_value)
|
||||
return stripped_value
|
||||
return value
|
||||
|
||||
@@ -71,6 +71,7 @@ class NapCatAdapterPlugin(MaiBotPlugin):
|
||||
version: 配置版本号。
|
||||
"""
|
||||
self.set_plugin_config(new_config)
|
||||
self._settings = None
|
||||
if version:
|
||||
self.ctx.logger.debug(f"NapCat 适配器收到配置更新通知: {version}")
|
||||
await self._restart_connection_if_needed()
|
||||
|
||||
Reference in New Issue
Block a user