mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-07 04:40:00 +08:00
✨ Feat: 增强 LLM、渲染与广播功能并优化性能 (#2071)
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
* ⚡️ perf(image_utils): 优化图片哈希获取避免阻塞异步 * ✨ feat(llm): 增强 LLM 管理功能,支持纯文本列表输出,优化模型能力识别并新增提供商 - 【LLM 管理器】为 `llm list` 命令添加 `--text` 选项,支持以纯文本格式输出模型列表。 - 【LLM 配置】新增 `OpenRouter` LLM 提供商的默认配置。 - 【模型能力】增强 `get_model_capabilities` 函数的查找逻辑,支持模型名称分段匹配和更灵活的通配符匹配。 - 【模型能力】为 `Gemini` 模型能力注册表使用更通用的通配符模式。 - 【模型能力】新增 `GPT` 系列模型的详细能力定义,包括多模态输入输出和工具调用支持。 * ✨ feat(renderer): 添加 Jinja2 `inline_asset` 全局函数 - 新增 `RendererService._inline_asset_global` 方法,并注册为 Jinja2 全局函数 `inline_asset`。 - 允许模板通过 `{{ inline_asset('@namespace/path/to/asset.svg') }}` 直接内联已注册命名空间下的资源文件内容。 - 主要用于解决内联 SVG 时可能遇到的跨域安全问题。 - 【重构】优化 `ResourceResolver.resolve_asset_uri` 中对命名空间资源 (以 `@` 开头) 的解析逻辑,确保能够正确获取文件绝对路径并返回 URI。 - 改进 `RenderableComponent.get_extra_css`,使其在组件定义 `component_css` 时自动返回该 CSS 内容。 - 清理 `Renderable` 协议和 `RenderableComponent` 基类中已存在方法的 `[新增]` 标记。 * ✨ feat(tag): 添加标签克隆功能 - 新增 `tag clone <源标签名> <新标签名>` 命令,用于复制现有标签。 - 【优化】在 `tag create`, `tag edit --add`, `tag edit --set` 命令中,自动去重传入的群组ID,避免重复关联。 * ✨ feat(broadcast): 实现标签定向广播、强制发送及并发控制 - 【新功能】 - 新增标签定向广播功能,支持通过 `-t <标签名>` 或 `广播到 <标签名>` 命令向指定标签的群组发送消息 - 引入广播强制发送模式,允许绕过群组的任务阻断设置 - 实现广播并发控制,通过配置限制同时发送任务数量,避免API速率限制 - 优化视频消息处理,支持从URL下载视频内容并作为原始数据发送,提高跨平台兼容性 - 【配置】 - 添加 `DEFAULT_BROADCAST` 配置项,用于设置群组进群时广播功能的默认开关状态 - 添加 `BROADCAST_CONCURRENCY_LIMIT` 配置项,用于控制广播时的最大并发任务数 * ✨ feat(renderer): 支持组件变体样式收集 * ✨ feat(tag): 实现群组标签自动清理及手动清理功能 * 🐛 fix(gemini): 增加响应验证以处理内容过滤(promptFeedback) * 🐛 fix(codeql): 移除对 JavaScript 和 TypeScript 的分析支持 * 🚨 auto fix by pre-commit hooks --------- Co-authored-by: webjoin111 <455457521@qq.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
c839b44256
commit
68460d18cc
@@ -354,6 +354,24 @@ class GeminiAdapter(BaseAdapter):
|
||||
|
||||
return safety_settings if safety_settings else None
|
||||
|
||||
def validate_response(self, response_json: dict[str, Any]) -> None:
|
||||
"""验证 Gemini API 响应,增加对 promptFeedback 的检查"""
|
||||
super().validate_response(response_json)
|
||||
|
||||
if prompt_feedback := response_json.get("promptFeedback"):
|
||||
if block_reason := prompt_feedback.get("blockReason"):
|
||||
logger.warning(
|
||||
f"Gemini 内容因 promptFeedback 被安全过滤: {block_reason}"
|
||||
)
|
||||
raise LLMException(
|
||||
f"内容被安全过滤: {block_reason}",
|
||||
code=LLMErrorCode.CONTENT_FILTERED,
|
||||
details={
|
||||
"block_reason": block_reason,
|
||||
"safety_ratings": prompt_feedback.get("safetyRatings"),
|
||||
},
|
||||
)
|
||||
|
||||
def parse_response(
|
||||
self,
|
||||
model: "LLMModel",
|
||||
|
||||
@@ -192,10 +192,20 @@ def get_default_providers() -> list[dict[str, Any]]:
|
||||
"api_base": "https://generativelanguage.googleapis.com",
|
||||
"api_type": "gemini",
|
||||
"models": [
|
||||
{"model_name": "gemini-2.0-flash"},
|
||||
{"model_name": "gemini-2.5-flash"},
|
||||
{"model_name": "gemini-2.5-pro"},
|
||||
{"model_name": "gemini-2.5-flash-lite-preview-06-17"},
|
||||
{"model_name": "gemini-2.5-flash-lite"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "OpenRouter",
|
||||
"api_key": "YOUR_OPENROUTER_API_KEY",
|
||||
"api_base": "https://openrouter.ai/api",
|
||||
"api_type": "openrouter",
|
||||
"models": [
|
||||
{"model_name": "google/gemini-2.5-pro"},
|
||||
{"model_name": "google/gemini-2.5-flash"},
|
||||
{"model_name": "x-ai/grok-4"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -9,6 +9,8 @@ import fnmatch
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
|
||||
class ModelModality(str, Enum):
|
||||
TEXT = "text"
|
||||
@@ -50,6 +52,46 @@ GEMINI_IMAGE_GEN_CAPABILITIES = ModelCapabilities(
|
||||
supports_tool_calling=True,
|
||||
)
|
||||
|
||||
GPT_ADVANCED_TEXT_IMAGE_CAPABILITIES = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT, ModelModality.IMAGE},
|
||||
output_modalities={ModelModality.TEXT},
|
||||
supports_tool_calling=True,
|
||||
)
|
||||
|
||||
GPT_MULTIMODAL_IO_CAPABILITIES = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT, ModelModality.AUDIO, ModelModality.IMAGE},
|
||||
output_modalities={ModelModality.TEXT, ModelModality.AUDIO},
|
||||
supports_tool_calling=True,
|
||||
)
|
||||
|
||||
GPT_IMAGE_GENERATION_CAPABILITIES = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT, ModelModality.IMAGE},
|
||||
output_modalities={ModelModality.IMAGE},
|
||||
supports_tool_calling=True,
|
||||
)
|
||||
|
||||
GPT_VIDEO_GENERATION_CAPABILITIES = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT, ModelModality.IMAGE, ModelModality.VIDEO},
|
||||
output_modalities={ModelModality.VIDEO},
|
||||
supports_tool_calling=True,
|
||||
)
|
||||
|
||||
DEFAULT_PERMISSIVE_CAPABILITIES = ModelCapabilities(
|
||||
input_modalities={
|
||||
ModelModality.TEXT,
|
||||
ModelModality.IMAGE,
|
||||
ModelModality.AUDIO,
|
||||
ModelModality.VIDEO,
|
||||
},
|
||||
output_modalities={
|
||||
ModelModality.TEXT,
|
||||
ModelModality.IMAGE,
|
||||
ModelModality.AUDIO,
|
||||
ModelModality.VIDEO,
|
||||
},
|
||||
supports_tool_calling=True,
|
||||
)
|
||||
|
||||
|
||||
DOUBAO_ADVANCED_MULTIMODAL_CAPABILITIES = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT, ModelModality.IMAGE, ModelModality.VIDEO},
|
||||
@@ -91,11 +133,8 @@ MODEL_CAPABILITIES_REGISTRY: dict[str, ModelCapabilities] = {
|
||||
is_embedding_model=True,
|
||||
),
|
||||
"*gemini-*-image-preview*": GEMINI_IMAGE_GEN_CAPABILITIES,
|
||||
"gemini-2.5-pro*": GEMINI_CAPABILITIES,
|
||||
"gemini-1.5-pro*": GEMINI_CAPABILITIES,
|
||||
"gemini-2.5-flash*": GEMINI_CAPABILITIES,
|
||||
"gemini-2.0-flash*": GEMINI_CAPABILITIES,
|
||||
"gemini-1.5-flash*": GEMINI_CAPABILITIES,
|
||||
"gemini-*-pro*": GEMINI_CAPABILITIES,
|
||||
"gemini-*-flash*": GEMINI_CAPABILITIES,
|
||||
"GLM-4V-Flash": ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT, ModelModality.IMAGE},
|
||||
output_modalities={ModelModality.TEXT},
|
||||
@@ -112,6 +151,13 @@ MODEL_CAPABILITIES_REGISTRY: dict[str, ModelCapabilities] = {
|
||||
"doubao-1-5-thinking-vision-pro": DOUBAO_ADVANCED_MULTIMODAL_CAPABILITIES,
|
||||
"deepseek-chat": STANDARD_TEXT_TOOL_CAPABILITIES,
|
||||
"deepseek-reasoner": STANDARD_TEXT_TOOL_CAPABILITIES,
|
||||
"gpt-5*": GPT_ADVANCED_TEXT_IMAGE_CAPABILITIES,
|
||||
"gpt-4.1*": GPT_ADVANCED_TEXT_IMAGE_CAPABILITIES,
|
||||
"gpt-4o*": GPT_MULTIMODAL_IO_CAPABILITIES,
|
||||
"o3*": GPT_ADVANCED_TEXT_IMAGE_CAPABILITIES,
|
||||
"o4-mini*": GPT_ADVANCED_TEXT_IMAGE_CAPABILITIES,
|
||||
"gpt image*": GPT_IMAGE_GENERATION_CAPABILITIES,
|
||||
"sora*": GPT_VIDEO_GENERATION_CAPABILITIES,
|
||||
}
|
||||
|
||||
|
||||
@@ -126,11 +172,25 @@ def get_model_capabilities(model_name: str) -> ModelCapabilities:
|
||||
canonical_name = c_name
|
||||
break
|
||||
|
||||
if canonical_name in MODEL_CAPABILITIES_REGISTRY:
|
||||
return MODEL_CAPABILITIES_REGISTRY[canonical_name]
|
||||
parts = canonical_name.split("/")
|
||||
names_to_check = ["/".join(parts[i:]) for i in range(len(parts))]
|
||||
|
||||
for pattern, capabilities in MODEL_CAPABILITIES_REGISTRY.items():
|
||||
if "*" in pattern and fnmatch.fnmatch(model_name, pattern):
|
||||
return capabilities
|
||||
logger.trace(f"为 '{model_name}' 生成的检查列表: {names_to_check}")
|
||||
|
||||
return ModelCapabilities()
|
||||
for name in names_to_check:
|
||||
if name in MODEL_CAPABILITIES_REGISTRY:
|
||||
logger.debug(f"模型 '{model_name}' 通过精确匹配 '{name}' 找到能力定义。")
|
||||
return MODEL_CAPABILITIES_REGISTRY[name]
|
||||
|
||||
for pattern, capabilities in MODEL_CAPABILITIES_REGISTRY.items():
|
||||
if "*" in pattern and fnmatch.fnmatch(name, pattern):
|
||||
logger.debug(
|
||||
f"模型 '{model_name}' 通过通配符匹配 '{name}'(pattern: '{pattern}')"
|
||||
f"找到能力定义。"
|
||||
)
|
||||
return capabilities
|
||||
|
||||
logger.warning(
|
||||
f"模型 '{model_name}' 的能力定义未在注册表中找到,将使用默认的'全功能'回退配置"
|
||||
)
|
||||
return DEFAULT_PERMISSIVE_CAPABILITIES
|
||||
|
||||
@@ -40,7 +40,7 @@ class Renderable(ABC):
|
||||
@abstractmethod
|
||||
def get_children(self) -> Iterable["Renderable"]:
|
||||
"""
|
||||
[新增] 返回一个包含所有直接子组件的可迭代对象。
|
||||
返回一个包含所有直接子组件的可迭代对象。
|
||||
|
||||
这使得渲染服务能够递归地遍历整个组件树,以执行依赖收集(CSS、JS)等任务。
|
||||
非容器组件应返回一个空列表。
|
||||
|
||||
@@ -75,6 +75,7 @@ class RendererService:
|
||||
self._custom_globals: dict[str, Callable] = {}
|
||||
|
||||
self.filter("dump_json")(self._pydantic_tojson_filter)
|
||||
self.global_function("inline_asset")(self._inline_asset_global)
|
||||
|
||||
def _create_jinja_env(self) -> Environment:
|
||||
"""
|
||||
@@ -176,9 +177,24 @@ class RendererService:
|
||||
|
||||
return decorator
|
||||
|
||||
async def _inline_asset_global(self, namespaced_path: str) -> str:
|
||||
"""
|
||||
一个Jinja2全局函数,用于读取并内联一个已注册命名空间下的资源文件内容。
|
||||
主要用于内联SVG,以解决浏览器的跨域安全问题。
|
||||
"""
|
||||
if not self._jinja_env or not self._jinja_env.loader:
|
||||
return f"<!-- Error: Jinja env not ready for {namespaced_path} -->"
|
||||
try:
|
||||
source, _, _ = self._jinja_env.loader.get_source(
|
||||
self._jinja_env, namespaced_path
|
||||
)
|
||||
return source
|
||||
except TemplateNotFound:
|
||||
return f"<!-- Asset not found: {namespaced_path} -->"
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
[新增] 延迟初始化方法,在 on_startup 钩子中调用。
|
||||
延迟初始化方法,在 on_startup 钩子中调用。
|
||||
|
||||
负责初始化截图引擎和主题管理器,确保在首次渲染前所有依赖都已准备就绪。
|
||||
使用锁来防止并发初始化。
|
||||
@@ -223,27 +239,36 @@ class RendererService:
|
||||
)
|
||||
|
||||
style_paths_to_load = []
|
||||
if manifest and "styles" in manifest:
|
||||
styles = (
|
||||
[manifest["styles"]]
|
||||
if isinstance(manifest["styles"], str)
|
||||
else manifest["styles"]
|
||||
)
|
||||
for style_path in styles:
|
||||
full_style_path = str(Path(component_path_base) / style_path).replace(
|
||||
"\\", "/"
|
||||
if manifest and manifest.get("styles"):
|
||||
styles = manifest["styles"]
|
||||
styles = [styles] if isinstance(styles, str) else styles
|
||||
|
||||
resolution_base_path = Path(component_path_base)
|
||||
if variant:
|
||||
skin_manifest_path = str(Path(component_path_base) / "skins" / variant)
|
||||
skin_manifest = await context.theme_manager._load_single_manifest(
|
||||
skin_manifest_path
|
||||
)
|
||||
style_paths_to_load.append(full_style_path)
|
||||
if skin_manifest and "styles" in skin_manifest:
|
||||
resolution_base_path = Path(skin_manifest_path)
|
||||
|
||||
style_paths_to_load.extend(
|
||||
str(resolution_base_path / style).replace("\\", "/") for style in styles
|
||||
)
|
||||
else:
|
||||
resolved_template_name = (
|
||||
base_template_path = (
|
||||
await context.theme_manager._resolve_component_template(
|
||||
component, context
|
||||
)
|
||||
)
|
||||
conventional_style_path = str(
|
||||
Path(resolved_template_name).with_name("style.css")
|
||||
base_style_path = str(
|
||||
Path(base_template_path).with_name("style.css")
|
||||
).replace("\\", "/")
|
||||
style_paths_to_load.append(conventional_style_path)
|
||||
style_paths_to_load.append(base_style_path)
|
||||
|
||||
if variant:
|
||||
skin_style_path = f"{component_path_base}/skins/{variant}/style.css"
|
||||
style_paths_to_load.append(skin_style_path)
|
||||
|
||||
for css_template_path in style_paths_to_load:
|
||||
try:
|
||||
|
||||
@@ -172,24 +172,45 @@ class ResourceResolver:
|
||||
|
||||
if asset_path.startswith("@"):
|
||||
try:
|
||||
full_asset_path = self.theme_manager.jinja_env.join_path(
|
||||
asset_path, current_template_name
|
||||
)
|
||||
_source, file_abs_path, _uptodate = (
|
||||
self.theme_manager.jinja_env.loader.get_source(
|
||||
self.theme_manager.jinja_env, full_asset_path
|
||||
if "/" not in asset_path:
|
||||
raise TemplateNotFound(f"无效的命名空间路径: {asset_path}")
|
||||
|
||||
namespace, rel_path = asset_path.split("/", 1)
|
||||
|
||||
loader = self.theme_manager.jinja_env.loader
|
||||
if (
|
||||
isinstance(loader, ChoiceLoader)
|
||||
and loader.loaders
|
||||
and isinstance(loader.loaders[0], PrefixLoader)
|
||||
):
|
||||
prefix_loader = loader.loaders[0]
|
||||
if namespace in prefix_loader.mapping:
|
||||
loader_for_namespace = prefix_loader.mapping[namespace]
|
||||
if isinstance(loader_for_namespace, FileSystemLoader):
|
||||
base_path = Path(loader_for_namespace.searchpath[0])
|
||||
file_abs_path = (base_path / rel_path).resolve()
|
||||
|
||||
if file_abs_path.is_file():
|
||||
logger.debug(
|
||||
f"Resolved namespaced asset"
|
||||
f" '{asset_path}' -> '{file_abs_path}'"
|
||||
)
|
||||
return file_abs_path.as_uri()
|
||||
else:
|
||||
raise TemplateNotFound(asset_path)
|
||||
else:
|
||||
raise TemplateNotFound(
|
||||
f"Unsupported loader type for namespace '{namespace}'."
|
||||
)
|
||||
else:
|
||||
raise TemplateNotFound(f"Namespace '{namespace}' not found.")
|
||||
else:
|
||||
raise TemplateNotFound(
|
||||
f"无法解析命名空间资源 '{asset_path}',加载器结构不符合预期。"
|
||||
)
|
||||
)
|
||||
if file_abs_path:
|
||||
logger.debug(
|
||||
f"Jinja Loader resolved asset '{asset_path}'->'{file_abs_path}'"
|
||||
)
|
||||
return Path(file_abs_path).absolute().as_uri()
|
||||
|
||||
except TemplateNotFound:
|
||||
logger.warning(
|
||||
f"资源文件在命名空间中未找到: '{asset_path}'"
|
||||
f"(在模板 '{current_template_name}' 中引用)"
|
||||
)
|
||||
logger.warning(f"资源文件在命名空间中未找到: '{asset_path}'")
|
||||
return ""
|
||||
|
||||
search_paths: list[tuple[str, Path]] = []
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any, ClassVar
|
||||
|
||||
from aiocache import Cache, cached
|
||||
from arclet.alconna import Alconna, Args
|
||||
import nonebot
|
||||
from nonebot.adapters import Bot
|
||||
from tortoise.exceptions import IntegrityError
|
||||
from tortoise.expressions import Q
|
||||
@@ -156,8 +157,9 @@ class TagManager:
|
||||
dynamic_rule=dynamic_rule,
|
||||
)
|
||||
if group_ids:
|
||||
unique_group_ids = list(dict.fromkeys(group_ids))
|
||||
await GroupTagLink.bulk_create(
|
||||
[GroupTagLink(tag=tag, group_id=gid) for gid in group_ids]
|
||||
[GroupTagLink(tag=tag, group_id=gid) for gid in unique_group_ids]
|
||||
)
|
||||
return tag
|
||||
|
||||
@@ -175,6 +177,49 @@ class TagManager:
|
||||
deleted_count = await GroupTag.filter(name=name).delete()
|
||||
return deleted_count > 0
|
||||
|
||||
@invalidate_on_change
|
||||
async def remove_group_from_all_tags(self, group_id: str) -> int:
|
||||
"""
|
||||
从所有静态标签中移除一个指定的群组ID。
|
||||
主要用于机器人退群时的实时清理。
|
||||
|
||||
参数:
|
||||
group_id: 要移除的群组ID。
|
||||
|
||||
返回:
|
||||
被删除的关联数量。
|
||||
"""
|
||||
deleted_count = await GroupTagLink.filter(group_id=group_id).delete()
|
||||
if deleted_count > 0:
|
||||
logger.info(f"已从 {deleted_count} 个标签中移除群组 {group_id} 的关联。")
|
||||
return deleted_count
|
||||
|
||||
@invalidate_on_change
|
||||
async def prune_stale_group_links(self) -> int:
|
||||
"""
|
||||
清理所有静态标签中无效的群组关联。
|
||||
无效指的是机器人已不再任何一个已连接的Bot的群组列表中。
|
||||
|
||||
返回:
|
||||
被清理的无效关联的总数。
|
||||
"""
|
||||
all_bot_group_ids = set()
|
||||
for bot in nonebot.get_bots().values():
|
||||
groups, _ = await PlatformUtils.get_group_list(bot)
|
||||
all_bot_group_ids.update(g.group_id for g in groups if g.group_id)
|
||||
|
||||
all_static_links = await GroupTagLink.filter(tag__tag_type="STATIC").all()
|
||||
|
||||
stale_link_ids = [
|
||||
link.id
|
||||
for link in all_static_links
|
||||
if link.group_id not in all_bot_group_ids
|
||||
]
|
||||
|
||||
if stale_link_ids:
|
||||
return await GroupTagLink.filter(id__in=stale_link_ids).delete()
|
||||
return 0
|
||||
|
||||
@invalidate_on_change
|
||||
async def add_groups_to_tag(self, name: str, group_ids: list[str]) -> int: # type: ignore
|
||||
"""
|
||||
@@ -186,11 +231,12 @@ class TagManager:
|
||||
if tag.tag_type == "DYNAMIC":
|
||||
raise ValueError("不能向动态标签手动添加群组。")
|
||||
|
||||
unique_group_ids = list(dict.fromkeys(group_ids))
|
||||
await GroupTagLink.bulk_create(
|
||||
[GroupTagLink(tag=tag, group_id=gid) for gid in group_ids],
|
||||
[GroupTagLink(tag=tag, group_id=gid) for gid in unique_group_ids],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
return len(group_ids)
|
||||
return len(unique_group_ids)
|
||||
|
||||
@invalidate_on_change
|
||||
async def remove_groups_from_tag(self, name: str, group_ids: list[str]) -> int:
|
||||
@@ -205,6 +251,72 @@ class TagManager:
|
||||
).delete()
|
||||
return deleted_count
|
||||
|
||||
@invalidate_on_change
|
||||
async def clone_tag(
|
||||
self,
|
||||
source_name: str,
|
||||
new_name: str,
|
||||
bot: Bot,
|
||||
add_groups: list[str] | None = None,
|
||||
remove_groups: list[str] | None = None,
|
||||
as_dynamic: bool = False,
|
||||
description: str | None = None,
|
||||
mode: str | None = None,
|
||||
) -> GroupTag:
|
||||
"""
|
||||
克隆一个标签,支持动态转静态、修改群组等。
|
||||
"""
|
||||
source_tag = await GroupTag.get_or_none(name=source_name)
|
||||
if not source_tag:
|
||||
raise ValueError(f"源标签 '{source_name}' 不存在。")
|
||||
|
||||
if await GroupTag.exists(name=new_name):
|
||||
raise IntegrityError(f"目标标签 '{new_name}' 已存在。")
|
||||
|
||||
tag_type = "STATIC"
|
||||
group_ids_to_set: list[str] | None = None
|
||||
dynamic_rule: str | dict | None = None
|
||||
|
||||
if source_tag.tag_type == "STATIC":
|
||||
if as_dynamic:
|
||||
raise ValueError("不能将静态标签克隆为动态标签。")
|
||||
group_ids_to_set = await GroupTagLink.filter(tag=source_tag).values_list( # type: ignore
|
||||
"group_id", flat=True
|
||||
)
|
||||
else:
|
||||
if as_dynamic:
|
||||
tag_type = "DYNAMIC"
|
||||
dynamic_rule = source_tag.dynamic_rule
|
||||
if add_groups or remove_groups:
|
||||
raise ValueError(
|
||||
"克隆为动态标签时,不支持 --add 或 --remove 操作。"
|
||||
)
|
||||
else:
|
||||
group_ids_to_set = await self.resolve_tag_to_group_ids(
|
||||
source_name, bot=bot
|
||||
)
|
||||
|
||||
if group_ids_to_set is not None:
|
||||
final_group_set = set(group_ids_to_set)
|
||||
if add_groups:
|
||||
final_group_set.update(add_groups)
|
||||
if remove_groups:
|
||||
final_group_set.difference_update(remove_groups)
|
||||
group_ids_to_set = list(final_group_set)
|
||||
|
||||
is_blacklist = (
|
||||
(mode == "black") if mode is not None else source_tag.is_blacklist
|
||||
)
|
||||
|
||||
return await self.create_tag(
|
||||
name=new_name,
|
||||
is_blacklist=is_blacklist,
|
||||
description=description,
|
||||
group_ids=group_ids_to_set,
|
||||
tag_type=tag_type,
|
||||
dynamic_rule=dynamic_rule,
|
||||
)
|
||||
|
||||
async def list_tags_with_counts(self) -> list[dict]:
|
||||
"""列出所有标签及其关联的群组数量。"""
|
||||
tags = await GroupTag.all().prefetch_related("groups")
|
||||
@@ -514,11 +626,13 @@ class TagManager:
|
||||
raise ValueError("不能为动态标签设置静态群组列表。")
|
||||
async with in_transaction():
|
||||
await GroupTagLink.filter(tag=tag).delete()
|
||||
await GroupTagLink.bulk_create(
|
||||
[GroupTagLink(tag=tag, group_id=gid) for gid in group_ids],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
return len(group_ids)
|
||||
unique_group_ids = list(dict.fromkeys(group_ids))
|
||||
if unique_group_ids:
|
||||
await GroupTagLink.bulk_create(
|
||||
[GroupTagLink(tag=tag, group_id=gid) for gid in unique_group_ids],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
return len(unique_group_ids)
|
||||
|
||||
@invalidate_on_change
|
||||
async def clear_all_tags(self) -> int:
|
||||
|
||||
Reference in New Issue
Block a user