版本更新 (#1666)

* ✨ 父级插件加载

* ✅ 添加测试:更新与添加插件 (#1594)

* ✅ 测试更新与添加插件

* ✅ Sourcery建议

* 👷 添加pytest

* 🎨 优化代码

* 🐛 bug修复

* 🐛修复添加插件返回403的问题 (#1595)

* 完善测试方法
* vscode测试配置
* 重构插件安装过程

* 🎨 修改readme

* Update README.md

* 🐛 修改bug与版本锁定

* 🐛 修复超级用户对群组功能开关

* 🐛 修复插件商店检查插件更新问题 (#1597)

* 🐛 修复插件商店检查插件更新问题

* 🐛 恶意命令检测问题

* 🐛 增加插件状态检查 (#1598)

* ✅ 优化测试用例

* 🐛 更改插件更新与安装逻辑

* 🐛 修复更新群组成员信息

* 🎨 代码优化

* 🚀 更新Dockerfile (#1599)

* 🎨 更新requirements

* ➕ 添加依赖aiocache

* ⚡ 添加github镜像

* ✨ 添加仓库目录多获取渠道

* 🐛 修复测试用例

* ✨ 添加API缓存

* 🎨 采取Sourcery建议

* 🐛 文件下载逻辑修改

* 🎨 优化代码

* 🐛 修复插件开关有时出现错误

* ✨ 重构自检ui

* 🐛 自检html修正

* 修复签到逻辑bug,并使代码更灵活以适应签到好感度等级配置 (#1606)

* 修复签到功能已知问题

* 修复签到功能已知问题

* 修改参数名称

* 修改uid判断

---------

Co-authored-by: HibiKier <45528451+HibiKier@users.noreply.github.com>

* 🎨 代码结构优化

* 🐛 私聊时修改插件时删除私聊帮助

* 🐛 过滤父插件

* 🐛 修复自检在ARM上的问题 (#1607)

* 🐛 修复自检在ARM上的问题

* ✅ 优化测试

* ✨ 支持mysql,psql,sqlite随机函数

* 🔧 VSCode配置修改

* 🔧 VSCode配置修改

* ✨ 添加金币排行

Co-Authored-By: HibiKier <45528451+HibiKier@users.noreply.github.com>

* 📝 修改README

Co-Authored-By: HibiKier <45528451+HibiKier@users.noreply.github.com>

* 🔨 提取GitHub相关操作 (#1609)

* 🔨 提取GitHub相关操作

* 🔨 重构API策略

* ✨ 签到/金币排行限制最大数量 (#1616)

* ✨ 签到/金币排行限制最大数量

* 🐛 修复超级用户id获取问题

* 🐛 修复路径解压与挂载 (#1619)

* 🐛 修复功能少时zhenxun帮助图片排序问题 (#1620)

* 🐛 签到文本适应 (#1622)

* 🐛 好感度排行提供默认值 (#1624)

* 🎈 优先使用github api (#1625)

* ✨ 重构帮助,限制普通用户查询管理插件 (#1626)

* 🐛 修复群权限与插件等级匹配 (#1627)

* ✨ 当管理员尝试ban真寻时将被反杀 (#1628)

* ✨ 群组发言时间检测提供开关配置 (#1630)

* 🐳 chore: 支持自动修改版本号 (#1629)

* 🎈 perf(github_utils): 支持github url下载遍历 (#1632)

* 🎈 perf(github_utils): 支持github url下载遍历

* 🐞 fix(http_utils): 修复一些下载问题

* 🦄 refactor(http_utils): 部分重构

* chore(version): Update version to v0.2.2-e6f17c4

---------

Co-authored-by: AkashiCoin <AkashiCoin@users.noreply.github.com>

* 🧪 test(auto_update): 修复测试用例 (#1633)

* 🐛 修复商店商品为空时报错 (#1634)

* 🐛 修复群权限与插件等级匹配 (#1635)

* ✨ message_build支持AtAll (#1639)

* 🎈 perf: 使用commit号下载插件 (#1641)

* 🎈 perf: 使用commit号下载插件

* chore(version): Update version to v0.2.2-f9c7360

---------

Co-authored-by: AkashiCoin <AkashiCoin@users.noreply.github.com>

* 🐳 chore: 修改运行检查触发路径 (#1642)

* 🐳 chore: 修改运行检查触发路径

* 🐳 chore: 添加tests目录

* ✨ 重构qq群事件处理 (#1643)

* 🐛 签到名称自适应 (#1644)

* 🎨  更新README (#1645)

* 🐛 fix(http_utils): 流式下载Content-Length错误 (#1647)

* 🐛 修复群组中帮助功能状态显示问题 (#1650)

* 🐛 修复群欢迎消息设置 (#1651)

* 🐛 修复webui下载后首次启动错误 (#1652)

* 🐛 修复webui下载后首次启动错误

* chore(version): Update version to v0.2.2-4a8ef85

---------

Co-authored-by: HibiKier <HibiKier@users.noreply.github.com>

* ✨ 移除默认图片文件夹:爬 (#1653)

* ✨ 安装/移除插件提供插件安装/卸载方法用于插件初始化 (#1654)

* ✨ 新增超级用户与管理员帮助模板 (#1655)

* ✨ 新增个人信息命令 (#1657)

* ✨ 修改个人信息菜单名称 (#1658)

* ✨ 新增插件商店api (#1659)

* ✨ 新增插件商店api

* chore(version): Update version to v0.2.2-7e15f20

---------

Co-authored-by: HibiKier <HibiKier@users.noreply.github.com>

* ✨ 将cd,block,count限制复原配置文件 (#1662)

* 🎨 修改README (#1663)

* 🎨 修改版本号 (#1664)

* 🎨 修改requirements (#1665)

---------

Co-authored-by: AkashiCoin <l1040186796@gmail.com>
Co-authored-by: fanyinrumeng <42991257+fanyinrumeng@users.noreply.github.com>
Co-authored-by: AkashiCoin <i@loli.vet>
Co-authored-by: Elaga <1728903318@qq.com>
Co-authored-by: AkashiCoin <AkashiCoin@users.noreply.github.com>
Co-authored-by: HibiKier <HibiKier@users.noreply.github.com>
This commit is contained in:
HibiKier
2024-10-01 00:42:23 +08:00
committed by GitHub
co-authored by AkashiCoin HibiKier AkashiCoin fanyinrumeng AkashiCoin Elaga
parent daa40ae8b8
commit 4e33bf3a50
216 changed files with 10281 additions and 2538 deletions
+57 -59
View File
@@ -1,15 +1,17 @@
import base64
import math
import uuid
import base64
import itertools
import contextlib
from io import BytesIO
from pathlib import Path
from typing import Literal, Tuple, TypeAlias, overload
from typing_extensions import Self
from typing import Literal, TypeAlias, overload
from nonebot.utils import run_sync
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from PIL.Image import Image as tImage
from PIL.ImageFont import FreeTypeFont
from typing_extensions import Self
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from zhenxun.configs.path_config import FONT_PATH
@@ -18,7 +20,7 @@ ModeType = Literal[
]
"""图片类型"""
ColorAlias: TypeAlias = str | Tuple[int, int, int] | Tuple[int, int, int, int] | None
ColorAlias: TypeAlias = str | tuple[int, int, int] | tuple[int, int, int, int] | None
CenterType = Literal["center", "height", "width"]
"""
@@ -52,9 +54,7 @@ class BuildImage:
self.height = height
self.color = color
self.font = (
self.load_font(font, font_size)
if not isinstance(font, FreeTypeFont)
else font
font if isinstance(font, FreeTypeFont) else self.load_font(font, font_size)
)
if background:
if isinstance(background, bytes):
@@ -66,14 +66,14 @@ class BuildImage:
else:
self.width = self.markImg.width
self.height = self.markImg.height
else:
if not width or not height:
raise ValueError("长度和宽度不能为空...")
elif width and height:
self.markImg = Image.new(mode, (width, height), color) # type: ignore
else:
raise ValueError("长度和宽度不能为空...")
self.draw = ImageDraw.Draw(self.markImg)
@property
def size(self) -> Tuple[int, int]:
def size(self) -> tuple[int, int]:
return self.markImg.size
@classmethod
@@ -94,9 +94,9 @@ class BuildImage:
text: str,
font: str | FreeTypeFont | Path = "HYWenHei-85W.ttf",
size: int = 10,
font_color: str | Tuple[int, int, int] = (0, 0, 0),
font_color: str | tuple[int, int, int] = (0, 0, 0),
color: ColorAlias = None,
padding: int | Tuple[int, int, int, int] | None = None,
padding: int | tuple[int, int, int, int] | None = None,
) -> Self:
"""构建文本图片
@@ -116,7 +116,7 @@ class BuildImage:
_font = None
if isinstance(font, FreeTypeFont):
_font = font
elif isinstance(font, (str, Path)):
elif isinstance(font, str | Path):
_font = cls.load_font(font, size)
width, height = cls.get_text_size(text, _font)
if isinstance(padding, int):
@@ -161,7 +161,7 @@ class BuildImage:
row_count = math.ceil(len(img_list) / row)
if row_count == 1:
background_width = (
sum([img.width for img in img_list]) + space * (row - 1) + padding * 2
sum(img.width for img in img_list) + space * (row - 1) + padding * 2
)
background_height = height * row_count + space * (row_count - 1) + padding * 2
background_image = cls(
@@ -189,20 +189,20 @@ class BuildImage:
返回:
FreeTypeFont: 字体
"""
path = FONT_PATH / font if type(font) == str else font
path = FONT_PATH / font if type(font) is str else font
return ImageFont.truetype(str(path), font_size)
@overload
@classmethod
def get_text_size(
cls, text: str, font: FreeTypeFont | None = None
) -> Tuple[int, int]: ...
) -> tuple[int, int]: ...
@overload
@classmethod
def get_text_size(
cls, text: str, font: str | None = None, font_size: int = 10
) -> Tuple[int, int]: ...
) -> tuple[int, int]: ...
@classmethod
def get_text_size(
@@ -210,7 +210,7 @@ class BuildImage:
text: str,
font: str | FreeTypeFont | None = "HYWenHei-85W.ttf",
font_size: int = 10,
) -> Tuple[int, int]:
) -> tuple[int, int]: # sourcery skip: remove-unnecessary-cast
"""获取该字体下文本需要的长宽
参数:
@@ -219,10 +219,10 @@ class BuildImage:
font_size: 字体大小
返回:
Tuple[int, int]: 长宽
tuple[int, int]: 长宽
"""
_font = font
if font and type(font) == str:
if font and type(font) is str:
_font = cls.load_font(font, font_size)
temp_image = Image.new("RGB", (1, 1), (255, 255, 255))
draw = ImageDraw.Draw(temp_image)
@@ -232,7 +232,8 @@ class BuildImage:
return text_width, text_height + 10
# return _font.getsize(str(text)) # type: ignore
def getsize(self, msg: str) -> Tuple[int, int]:
def getsize(self, msg: str) -> tuple[int, int]:
# sourcery skip: remove-unnecessary-cast
"""
获取文字在该图片 font_size 下所需要的空间
@@ -240,7 +241,7 @@ class BuildImage:
msg: 文本
返回:
Tuple[int, int]: 长宽
tuple[int, int]: 长宽
"""
temp_image = Image.new("RGB", (1, 1), (255, 255, 255))
draw = ImageDraw.Draw(temp_image)
@@ -252,11 +253,11 @@ class BuildImage:
def __center_xy(
self,
pos: Tuple[int, int],
pos: tuple[int, int],
width: int,
height: int,
center_type: CenterType | None,
) -> Tuple[int, int]:
) -> tuple[int, int]:
"""
根据居中类型定位xy
@@ -266,7 +267,7 @@ class BuildImage:
center_type: 居中类型
返回:
Tuple[int, int]: 定位
tuple[int, int]: 定位
"""
# _width, _height = pos
if self.width and self.height:
@@ -285,7 +286,7 @@ class BuildImage:
def paste(
self,
image: Self | tImage,
pos: Tuple[int, int] = (0, 0),
pos: tuple[int, int] = (0, 0),
center_type: CenterType | None = None,
) -> Self:
"""贴图
@@ -303,7 +304,6 @@ class BuildImage:
"""
if center_type and center_type not in ["center", "height", "width"]:
raise ValueError("center_type must be 'center', 'width' or 'height'")
width, height = 0, 0
_image = image
if isinstance(image, BuildImage):
_image = image.markImg
@@ -317,7 +317,7 @@ class BuildImage:
@run_sync
def point(
self, pos: Tuple[int, int], fill: Tuple[int, int, int] | None = None
self, pos: tuple[int, int], fill: tuple[int, int, int] | None = None
) -> Self:
"""
绘制多个或单独的像素
@@ -335,9 +335,9 @@ class BuildImage:
@run_sync
def ellipse(
self,
pos: Tuple[int, int, int, int],
fill: Tuple[int, int, int] | None = None,
outline: Tuple[int, int, int] | None = None,
pos: tuple[int, int, int, int],
fill: tuple[int, int, int] | None = None,
outline: tuple[int, int, int] | None = None,
width: int = 1,
) -> Self:
"""
@@ -358,13 +358,13 @@ class BuildImage:
@run_sync
def text(
self,
pos: Tuple[int, int],
pos: tuple[int, int],
text: str,
fill: str | Tuple[int, int, int] = (0, 0, 0),
fill: str | tuple[int, int, int] = (0, 0, 0),
center_type: CenterType | None = None,
font: FreeTypeFont | str | Path | None = None,
font_size: int = 10,
) -> Self:
) -> Self: # sourcery skip: remove-unnecessary-cast
"""
在图片上添加文字
@@ -382,11 +382,10 @@ class BuildImage:
异常:
ValueError: 居中类型错误
"""
text = str(text)
if center_type and center_type not in ["center", "height", "width"]:
raise ValueError("center_type must be 'center', 'width' or 'height'")
max_length_text = ""
sentence = text.split("\n")
sentence = str(text).split("\n")
for x in sentence:
max_length_text = x if len(x) > len(max_length_text) else max_length_text
if font:
@@ -398,7 +397,7 @@ class BuildImage:
ttf_w, ttf_h = self.getsize(max_length_text) # type: ignore
# ttf_h = ttf_h * len(sentence)
pos = self.__center_xy(pos, ttf_w, ttf_h, center_type)
self.draw.text(pos, text, fill=fill, font=font)
self.draw.text(pos, str(text), fill=fill, font=font)
return self
@run_sync
@@ -437,7 +436,7 @@ class BuildImage:
if not width and not height and not ratio:
raise ValueError("缺少参数...")
if self.width and self.height:
if not width and not height and ratio:
if not width and not height:
width = int(self.width * ratio)
height = int(self.height * ratio)
self.markImg = self.markImg.resize((width, height), Image.LANCZOS) # type: ignore
@@ -446,7 +445,7 @@ class BuildImage:
return self
@run_sync
def crop(self, box: Tuple[int, int, int, int]) -> Self:
def crop(self, box: tuple[int, int, int, int]) -> Self:
"""
裁剪图片
@@ -475,11 +474,10 @@ class BuildImage:
"""
self.markImg = self.markImg.convert("RGBA")
x, y = self.markImg.size
for i in range(n, x - n):
for k in range(n, y - n):
color = self.markImg.getpixel((i, k))
color = color[:-1] + (int(100 * alpha_ratio),)
self.markImg.putpixel((i, k), color)
for i, k in itertools.product(range(n, x - n), range(n, y - n)):
color = self.markImg.getpixel((i, k))
color = color[:-1] + (int(100 * alpha_ratio),)
self.markImg.putpixel((i, k), color)
self.draw = ImageDraw.Draw(self.markImg)
return self
@@ -492,7 +490,7 @@ class BuildImage:
buf = BytesIO()
self.markImg.save(buf, format="PNG")
base64_str = base64.b64encode(buf.getvalue()).decode()
return "base64://" + base64_str
return f"base64://{base64_str}"
def pic2bytes(self) -> bytes:
"""获取bytes
@@ -520,8 +518,8 @@ class BuildImage:
@run_sync
def rectangle(
self,
xy: Tuple[int, int, int, int],
fill: Tuple[int, int, int] | None = None,
xy: tuple[int, int, int, int],
fill: tuple[int, int, int] | None = None,
outline: str | None = None,
width: int = 1,
) -> Self:
@@ -543,8 +541,8 @@ class BuildImage:
@run_sync
def polygon(
self,
xy: list[Tuple[int, int]],
fill: Tuple[int, int, int] = (0, 0, 0),
xy: list[tuple[int, int]],
fill: tuple[int, int, int] = (0, 0, 0),
outline: int = 1,
) -> Self:
"""
@@ -564,8 +562,8 @@ class BuildImage:
@run_sync
def line(
self,
xy: Tuple[int, int, int, int],
fill: Tuple[int, int, int] | str = "#D8DEE4",
xy: tuple[int, int, int, int],
fill: tuple[int, int, int] | str = "#D8DEE4",
width: int = 1,
) -> Self:
"""
@@ -605,21 +603,19 @@ class BuildImage:
)
draw = ImageDraw.Draw(mask)
for offset, fill in (width / -2.0, "black"), (width / 2.0, "white"):
left, top = [(value + offset) * antialias for value in ellipse_box[:2]]
right, bottom = [(value - offset) * antialias for value in ellipse_box[2:]]
left, top = ((value + offset) * antialias for value in ellipse_box[:2])
right, bottom = ((value - offset) * antialias for value in ellipse_box[2:])
draw.ellipse([left, top, right, bottom], fill=fill)
mask = mask.resize(self.markImg.size, Image.LANCZOS)
try:
with contextlib.suppress(ValueError):
self.markImg.putalpha(mask)
except ValueError:
pass
return self
@run_sync
def circle_corner(
self,
radii: int = 30,
point_list: list[Literal["lt", "rt", "lb", "rb"]] = ["lt", "rt", "lb", "rb"],
point_list: list[Literal["lt", "rt", "lb", "rb"]] | None = None,
) -> Self:
"""
矩形四角变圆
@@ -631,6 +627,8 @@ class BuildImage:
返回:
BuildImage: Self
"""
if point_list is None:
point_list = ["lt", "rt", "lb", "rb"]
# 画圆(用于分离4个角)
img = self.markImg.convert("RGBA")
alpha = img.split()[-1]
+9 -11
View File
@@ -1,11 +1,10 @@
import random
from io import BytesIO
from pathlib import Path
from typing import Any, Callable, Dict
from collections.abc import Callable
from fastapi import background
from PIL.ImageFont import FreeTypeFont
from pydantic import BaseModel
from PIL.ImageFont import FreeTypeFont
from ._build_image import BuildImage
@@ -25,13 +24,13 @@ class RowStyle(BaseModel):
class ImageTemplate:
color_list = ["#C2CEFE", "#FFA94C", "#3FE6A0", "#D1D4F5"]
color_list = ["#C2CEFE", "#FFA94C", "#3FE6A0", "#D1D4F5"] # noqa: RUF012
@classmethod
async def hl_page(
cls,
head_text: str,
items: Dict[str, str],
items: dict[str, str],
row_space: int = 10,
padding: int = 30,
) -> BuildImage:
@@ -162,9 +161,9 @@ class ImageTemplate:
column_data = []
for i in range(len(column_name)):
c = []
for l in data_list:
if len(l) > i:
c.append(l[i])
for lst in data_list:
if len(lst) > i:
c.append(lst[i])
else:
c.append("")
column_data.append(c)
@@ -188,7 +187,7 @@ class ImageTemplate:
column_name[i], font, 12, "#C8CCCF"
)
column_name_image_list.append(column_name_image)
max_h = max([c.height for c in column_name_image_list])
max_h = max(c.height for c in column_name_image_list)
for i, data in enumerate(build_data_list):
width = data["width"] + padding * 2
height = (base_h + row_space) * (len(data["data"]) + 1) + padding * 2
@@ -280,10 +279,9 @@ class ImageTemplate:
width = 0
height = 0
_, h = BuildImage.get_text_size("A", font)
image_list = []
for s in text.split("\n"):
s = s.strip() or "A"
w, _ = BuildImage.get_text_size(s, font)
width = width if width > w else w
width = max(width, w)
height += h
return width, height
+18
View File
@@ -1,3 +1,5 @@
from zhenxun.services.log import logger
from zhenxun.configs.config import BotConfig
from zhenxun.models.task_info import TaskInfo
from zhenxun.models.ban_console import BanConsole
from zhenxun.models.group_console import GroupConsole
@@ -34,3 +36,19 @@ class CommonUtils:
"""群组是否被ban"""
return True
return False
class SqlUtils:
@classmethod
def random(cls, query, limit: int = 1) -> str:
db_class_name = BotConfig.get_sql_type()
if "postgres" in db_class_name or "sqlite" in db_class_name:
query = f"{query.sql()} ORDER BY RANDOM() LIMIT {limit};"
elif "mysql" in db_class_name:
query = f"{query.sql()} ORDER BY RAND() LIMIT {limit};"
else:
logger.warning(
f"Unsupported database type: {db_class_name}", query.__module__
)
return query
+2
View File
@@ -42,6 +42,8 @@ class PluginType(StrEnum):
"""依赖插件,一般为没有主动触发命令的插件,受权限控制"""
HIDDEN = "HIDDEN"
"""隐藏插件,一般为没有主动触发命令的插件,不受权限控制,如消息统计"""
PARENT = "PARENT"
"""父插件,仅仅标记"""
class BlockType(StrEnum):
+27
View File
@@ -0,0 +1,27 @@
from collections.abc import Generator
from .consts import GITHUB_REPO_URL_PATTERN
from .func import get_fastest_raw_formats, get_fastest_archive_formats
from .models import RepoAPI, RepoInfo, GitHubStrategy, JsdelivrStrategy
__all__ = [
"get_fastest_raw_formats",
"get_fastest_archive_formats",
"GithubUtils",
]
class GithubUtils:
# 使用
jsdelivr_api = RepoAPI(JsdelivrStrategy()) # type: ignore
github_api = RepoAPI(GitHubStrategy()) # type: ignore
@classmethod
def iter_api_strategies(cls) -> Generator[RepoAPI]:
yield from [cls.github_api, cls.jsdelivr_api]
@classmethod
def parse_github_url(cls, github_url: str) -> "RepoInfo":
if matched := GITHUB_REPO_URL_PATTERN.match(github_url):
return RepoInfo(**{k: v for k, v in matched.groupdict().items() if v})
raise ValueError("github地址格式错误")
+35
View File
@@ -0,0 +1,35 @@
import re
GITHUB_REPO_URL_PATTERN = re.compile(
r"^https://github.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)(/tree/(?P<branch>[^/]+))?$"
)
"""github仓库地址正则"""
JSD_PACKAGE_API_FORMAT = (
"https://data.jsdelivr.com/v1/packages/gh/{owner}/{repo}@{branch}"
)
"""jsdelivr包地址格式"""
GIT_API_TREES_FORMAT = (
"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1"
)
"""git api trees地址格式"""
CACHED_API_TTL = 300
"""缓存api ttl"""
RAW_CONTENT_FORMAT = "https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}"
"""raw content格式"""
ARCHIVE_URL_FORMAT = "https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip"
"""archive url格式"""
RELEASE_ASSETS_FORMAT = (
"https://github.com/{owner}/{repo}/releases/download/{version}/{filename}"
)
"""release assets格式"""
RELEASE_SOURCE_FORMAT = (
"https://codeload.github.com/{owner}/{repo}/legacy.{compress}/refs/tags/{version}"
)
"""release 源码格式"""
+63
View File
@@ -0,0 +1,63 @@
from aiocache import cached
from ..http_utils import AsyncHttpx
from .consts import (
ARCHIVE_URL_FORMAT,
RAW_CONTENT_FORMAT,
RELEASE_ASSETS_FORMAT,
RELEASE_SOURCE_FORMAT,
)
async def __get_fastest_formats(formats: dict[str, str]) -> list[str]:
sorted_urls = await AsyncHttpx.get_fastest_mirror(list(formats.keys()))
if not sorted_urls:
raise Exception("无法获取任意GitHub资源加速地址,请检查网络")
return [formats[url] for url in sorted_urls]
@cached()
async def get_fastest_raw_formats() -> list[str]:
"""获取最快的raw下载地址格式"""
formats: dict[str, str] = {
"https://raw.githubusercontent.com/": RAW_CONTENT_FORMAT,
"https://ghproxy.cc/": f"https://ghproxy.cc/{RAW_CONTENT_FORMAT}",
"https://mirror.ghproxy.com/": f"https://mirror.ghproxy.com/{RAW_CONTENT_FORMAT}",
"https://gh-proxy.com/": f"https://gh-proxy.com/{RAW_CONTENT_FORMAT}",
"https://cdn.jsdelivr.net/": "https://cdn.jsdelivr.net/gh/{owner}/{repo}@{branch}/{path}",
}
return await __get_fastest_formats(formats)
@cached()
async def get_fastest_archive_formats() -> list[str]:
"""获取最快的归档下载地址格式"""
formats: dict[str, str] = {
"https://github.com/": ARCHIVE_URL_FORMAT,
"https://ghproxy.cc/": f"https://ghproxy.cc/{ARCHIVE_URL_FORMAT}",
"https://mirror.ghproxy.com/": f"https://mirror.ghproxy.com/{ARCHIVE_URL_FORMAT}",
"https://gh-proxy.com/": f"https://gh-proxy.com/{ARCHIVE_URL_FORMAT}",
}
return await __get_fastest_formats(formats)
@cached()
async def get_fastest_release_formats() -> list[str]:
"""获取最快的发行版资源下载地址格式"""
formats: dict[str, str] = {
"https://objects.githubusercontent.com/": RELEASE_ASSETS_FORMAT,
"https://ghproxy.cc/": f"https://ghproxy.cc/{RELEASE_ASSETS_FORMAT}",
"https://mirror.ghproxy.com/": f"https://mirror.ghproxy.com/{RELEASE_ASSETS_FORMAT}",
"https://gh-proxy.com/": f"https://gh-proxy.com/{RELEASE_ASSETS_FORMAT}",
}
return await __get_fastest_formats(formats)
@cached()
async def get_fastest_release_source_formats() -> list[str]:
"""获取最快的发行版源码下载地址格式"""
formats: dict[str, str] = {
"https://codeload.github.com/": RELEASE_SOURCE_FORMAT,
"https://p.102333.xyz/": f"https://p.102333.xyz/{RELEASE_SOURCE_FORMAT}",
}
return await __get_fastest_formats(formats)
+232
View File
@@ -0,0 +1,232 @@
from typing import Protocol
from aiocache import cached
from strenum import StrEnum
from pydantic import BaseModel
from ..http_utils import AsyncHttpx
from .consts import CACHED_API_TTL, GIT_API_TREES_FORMAT, JSD_PACKAGE_API_FORMAT
from .func import (
get_fastest_raw_formats,
get_fastest_archive_formats,
get_fastest_release_source_formats,
)
class RepoInfo(BaseModel):
"""仓库信息"""
owner: str
repo: str
branch: str = "main"
async def get_raw_download_url(self, path: str) -> str:
return (await self.get_raw_download_urls(path))[0]
async def get_archive_download_url(self) -> str:
return (await self.get_archive_download_urls())[0]
async def get_release_source_download_url_tgz(self, version: str) -> str:
return (await self.get_release_source_download_urls_tgz(version))[0]
async def get_release_source_download_url_zip(self, version: str) -> str:
return (await self.get_release_source_download_urls_zip(version))[0]
async def get_raw_download_urls(self, path: str) -> list[str]:
url_formats = await get_fastest_raw_formats()
return [
url_format.format(**self.dict(), path=path) for url_format in url_formats
]
async def get_archive_download_urls(self) -> list[str]:
url_formats = await get_fastest_archive_formats()
return [url_format.format(**self.dict()) for url_format in url_formats]
async def get_release_source_download_urls_tgz(self, version: str) -> list[str]:
url_formats = await get_fastest_release_source_formats()
return [
url_format.format(**self.dict(), version=version, compress="tar.gz")
for url_format in url_formats
]
async def get_release_source_download_urls_zip(self, version: str) -> list[str]:
url_formats = await get_fastest_release_source_formats()
return [
url_format.format(**self.dict(), version=version, compress="zip")
for url_format in url_formats
]
class APIStrategy(Protocol):
"""API策略"""
body: BaseModel
async def parse_repo_info(self, repo_info: RepoInfo) -> BaseModel: ...
def get_files(self, module_path: str, is_dir: bool) -> list[str]: ...
class RepoAPI:
"""基础接口"""
def __init__(self, strategy: APIStrategy):
self.strategy = strategy
async def parse_repo_info(self, repo_info: RepoInfo):
body = await self.strategy.parse_repo_info(repo_info)
self.strategy.body = body
def get_files(self, module_path: str, is_dir: bool) -> list[str]:
return self.strategy.get_files(module_path, is_dir)
class FileType(StrEnum):
"""文件类型"""
FILE = "file"
DIR = "directory"
PACKAGE = "gh"
class FileInfo(BaseModel):
"""文件信息"""
type: FileType
name: str
files: list["FileInfo"] = []
class JsdelivrStrategy:
"""Jsdelivr策略"""
body: FileInfo
def get_file_paths(self, module_path: str, is_dir: bool = True) -> list[str]:
"""获取文件路径"""
paths = module_path.split("/")
filename = "" if is_dir else paths[-1]
paths = paths if is_dir else paths[:-1]
cur_file = self.body
for path in paths: # 导航到正确的目录
cur_file = next(
(
f
for f in cur_file.files
if f.type == FileType.DIR and f.name == path
),
None,
)
if not cur_file:
raise ValueError(f"模块路径{module_path}不存在")
def collect_files(file: FileInfo, current_path: str, filename: str):
"""收集文件"""
if file.type == FileType.FILE and (not filename or file.name == filename):
return [f"{current_path}/{file.name}"]
elif file.type == FileType.DIR and file.files:
return [
path
for f in file.files
for path in collect_files(
f,
(
f"{current_path}/{f.name}"
if f.type == FileType.DIR
else current_path
),
filename,
)
]
return []
return collect_files(cur_file, "/".join(paths), filename)
@classmethod
@cached(ttl=CACHED_API_TTL)
async def parse_repo_info(cls, repo_info: RepoInfo) -> "FileInfo":
"""解析仓库信息"""
"""获取插件包信息
参数:
repo_info: 仓库信息
返回:
FileInfo: 插件包信息
"""
jsd_package_url: str = JSD_PACKAGE_API_FORMAT.format(
owner=repo_info.owner, repo=repo_info.repo, branch=repo_info.branch
)
res = await AsyncHttpx.get(url=jsd_package_url)
if res.status_code != 200:
raise ValueError(f"下载错误, code: {res.status_code}")
return FileInfo(**res.json())
def get_files(self, module_path: str, is_dir: bool = True) -> list[str]:
"""获取文件路径"""
return self.get_file_paths(module_path, is_dir)
class TreeType(StrEnum):
"""树类型"""
FILE = "blob"
DIR = "tree"
class Tree(BaseModel):
"""树"""
path: str
mode: str
type: TreeType
sha: str
size: int | None
url: str
class TreeInfo(BaseModel):
"""树信息"""
sha: str
url: str
tree: list[Tree]
class GitHubStrategy:
"""GitHub策略"""
body: TreeInfo
def export_files(self, module_path: str) -> list[str]:
"""导出文件路径"""
tree_info = self.body
return [
file.path
for file in tree_info.tree
if file.type == TreeType.FILE and file.path.startswith(module_path)
]
@classmethod
@cached(ttl=CACHED_API_TTL)
async def parse_repo_info(cls, repo_info: RepoInfo) -> "TreeInfo":
"""获取仓库树
参数:
repo_info: 仓库信息
返回:
TreesInfo: 仓库树信息
"""
git_tree_url: str = GIT_API_TREES_FORMAT.format(
owner=repo_info.owner, repo=repo_info.repo, branch=repo_info.branch
)
res = await AsyncHttpx.get(url=git_tree_url)
if res.status_code != 200:
raise ValueError(f"下载错误, code: {res.status_code}")
return TreeInfo(**res.json())
def get_files(self, module_path: str, is_dir: bool = True) -> list[str]:
"""获取文件路径"""
return self.export_files(module_path)
+220 -89
View File
@@ -1,20 +1,22 @@
import time
import asyncio
from asyncio.exceptions import TimeoutError
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, AsyncGenerator, Dict, Literal
from typing import Any, Literal, ClassVar
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from asyncio.exceptions import TimeoutError
import aiofiles
import httpx
import rich
from httpx import ConnectTimeout, Response
import httpx
import aiofiles
from retrying import retry
from playwright.async_api import Page
from nonebot_plugin_alconna import UniMessage
from nonebot_plugin_htmlrender import get_browser
from playwright.async_api import Page
from retrying import retry
from httpx import Response, ConnectTimeout, HTTPStatusError
from zhenxun.configs.config import BotConfig
from zhenxun.services.log import logger
from zhenxun.configs.config import BotConfig
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.user_agent import get_user_agent
@@ -22,21 +24,105 @@ from zhenxun.utils.user_agent import get_user_agent
class AsyncHttpx:
proxy = {"http://": BotConfig.system_proxy, "https://": BotConfig.system_proxy}
proxy: ClassVar[dict[str, str | None]] = {
"http://": BotConfig.system_proxy,
"https://": BotConfig.system_proxy,
}
@classmethod
@retry(stop_max_attempt_number=3)
async def get(
cls,
url: str,
url: str | list[str],
*,
params: Dict[str, Any] | None = None,
headers: Dict[str, str] | None = None,
cookies: Dict[str, str] | None = None,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
cookies: dict[str, str] | None = None,
verify: bool = True,
use_proxy: bool = True,
proxy: Dict[str, str] | None = None,
proxy: dict[str, str] | None = None,
timeout: int = 30,
**kwargs,
) -> Response:
"""Get
参数:
url: url
params: params
headers: 请求头
cookies: cookies
verify: verify
use_proxy: 使用默认代理
proxy: 指定代理
timeout: 超时时间
"""
urls = [url] if isinstance(url, str) else url
return await cls._get_first_successful(
urls,
params=params,
headers=headers,
cookies=cookies,
verify=verify,
use_proxy=use_proxy,
proxy=proxy,
timeout=timeout,
**kwargs,
)
@classmethod
async def _get_first_successful(
cls,
urls: list[str],
**kwargs,
) -> Response:
last_exception = None
for url in urls:
try:
return await cls._get_single(url, **kwargs)
except Exception as e:
last_exception = e
if url != urls[-1]:
logger.warning(f"获取 {url} 失败, 尝试下一个")
raise last_exception or Exception("All URLs failed")
@classmethod
async def _get_single(
cls,
url: str,
*,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
cookies: dict[str, str] | None = None,
verify: bool = True,
use_proxy: bool = True,
proxy: dict[str, str] | None = None,
timeout: int = 30,
**kwargs,
) -> Response:
if not headers:
headers = get_user_agent()
_proxy = proxy if proxy else cls.proxy if use_proxy else None
async with httpx.AsyncClient(proxies=_proxy, verify=verify) as client: # type: ignore
return await client.get(
url,
params=params,
headers=headers,
cookies=cookies,
timeout=timeout,
**kwargs,
)
@classmethod
async def head(
cls,
url: str,
*,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
cookies: dict[str, str] | None = None,
verify: bool = True,
use_proxy: bool = True,
proxy: dict[str, str] | None = None,
timeout: int = 30,
**kwargs,
) -> Response:
@@ -56,7 +142,7 @@ class AsyncHttpx:
headers = get_user_agent()
_proxy = proxy if proxy else cls.proxy if use_proxy else None
async with httpx.AsyncClient(proxies=_proxy, verify=verify) as client: # type: ignore
return await client.get(
return await client.head(
url,
params=params,
headers=headers,
@@ -70,16 +156,16 @@ class AsyncHttpx:
cls,
url: str,
*,
data: Dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
content: Any = None,
files: Any = None,
verify: bool = True,
use_proxy: bool = True,
proxy: Dict[str, str] | None = None,
json: Dict[str, Any] | None = None,
params: Dict[str, str] | None = None,
headers: Dict[str, str] | None = None,
cookies: Dict[str, str] | None = None,
proxy: dict[str, str] | None = None,
json: dict[str, Any] | None = None,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
cookies: dict[str, str] | None = None,
timeout: int = 30,
**kwargs,
) -> Response:
@@ -119,17 +205,18 @@ class AsyncHttpx:
@classmethod
async def download_file(
cls,
url: str,
url: str | list[str],
path: str | Path,
*,
params: Dict[str, str] | None = None,
params: dict[str, str] | None = None,
verify: bool = True,
use_proxy: bool = True,
proxy: Dict[str, str] | None = None,
headers: Dict[str, str] | None = None,
cookies: Dict[str, str] | None = None,
proxy: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
cookies: dict[str, str] | None = None,
timeout: int = 30,
stream: bool = False,
follow_redirects: bool = True,
**kwargs,
) -> bool:
"""下载文件
@@ -151,71 +238,82 @@ class AsyncHttpx:
path.parent.mkdir(parents=True, exist_ok=True)
try:
for _ in range(3):
if not stream:
if not isinstance(url, list):
url = [url]
for u in url:
try:
content = (
await cls.get(
url,
if not stream:
response = await cls.get(
u,
params=params,
headers=headers,
cookies=cookies,
use_proxy=use_proxy,
proxy=proxy,
timeout=timeout,
follow_redirects=follow_redirects,
**kwargs,
)
).content
async with aiofiles.open(path, "wb") as wf:
await wf.write(content)
logger.info(f"下载 {url} 成功.. Path:{path.absolute()}")
return True
except (TimeoutError, ConnectTimeout):
pass
else:
if not headers:
headers = get_user_agent()
_proxy = proxy if proxy else cls.proxy if use_proxy else None
try:
async with httpx.AsyncClient(
proxies=_proxy, verify=verify # type: ignore
) as client:
async with client.stream(
"GET",
url,
params=params,
headers=headers,
cookies=cookies,
timeout=timeout,
**kwargs,
) as response:
logger.info(
f"开始下载 {path.name}.. Path: {path.absolute()}"
)
async with aiofiles.open(path, "wb") as wf:
total = int(response.headers["Content-Length"])
with rich.progress.Progress( # type: ignore
rich.progress.TextColumn(path.name), # type: ignore
"[progress.percentage]{task.percentage:>3.0f}%", # type: ignore
rich.progress.BarColumn(bar_width=None), # type: ignore
rich.progress.DownloadColumn(), # type: ignore
rich.progress.TransferSpeedColumn(), # type: ignore
) as progress:
download_task = progress.add_task(
"Download", total=total
)
async for chunk in response.aiter_bytes():
await wf.write(chunk)
await wf.flush()
progress.update(
download_task,
completed=response.num_bytes_downloaded,
)
response.raise_for_status()
content = response.content
async with aiofiles.open(path, "wb") as wf:
await wf.write(content)
logger.info(f"下载 {u} 成功.. Path:{path.absolute()}")
return True
else:
if not headers:
headers = get_user_agent()
_proxy = (
proxy if proxy else cls.proxy if use_proxy else None
)
async with httpx.AsyncClient(
proxies=_proxy, # type: ignore
verify=verify,
) as client:
async with client.stream(
"GET",
u,
params=params,
headers=headers,
cookies=cookies,
timeout=timeout,
follow_redirects=True,
**kwargs,
) as response:
response.raise_for_status()
logger.info(
f"下载 {url} 成功.. Path:{path.absolute()}"
f"开始下载 {path.name}.. "
f"Path: {path.absolute()}"
)
return True
except (TimeoutError, ConnectTimeout):
pass
async with aiofiles.open(path, "wb") as wf:
total = int(
response.headers.get("Content-Length", 0)
)
with rich.progress.Progress( # type: ignore
rich.progress.TextColumn(path.name), # type: ignore
"[progress.percentage]{task.percentage:>3.0f}%", # type: ignore
rich.progress.BarColumn(bar_width=None), # type: ignore
rich.progress.DownloadColumn(), # type: ignore
rich.progress.TransferSpeedColumn(), # type: ignore
) as progress:
download_task = progress.add_task(
"Download",
total=total if total else None,
)
async for chunk in response.aiter_bytes():
await wf.write(chunk)
await wf.flush()
progress.update(
download_task,
completed=response.num_bytes_downloaded,
)
logger.info(
f"下载 {u} 成功.. "
f"Path:{path.absolute()}"
)
return True
except (TimeoutError, ConnectTimeout, HTTPStatusError):
logger.warning(f"下载 {u} 失败.. 尝试下一个地址..")
else:
logger.error(f"下载 {url} 下载超时.. Path:{path.absolute()}")
except Exception as e:
@@ -225,15 +323,15 @@ class AsyncHttpx:
@classmethod
async def gather_download_file(
cls,
url_list: list[str],
url_list: list[str] | list[list[str]],
path_list: list[str | Path],
*,
limit_async_number: int | None = None,
params: Dict[str, str] | None = None,
params: dict[str, str] | None = None,
use_proxy: bool = True,
proxy: Dict[str, str] | None = None,
headers: Dict[str, str] | None = None,
cookies: Dict[str, str] | None = None,
proxy: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
cookies: dict[str, str] | None = None,
timeout: int = 30,
**kwargs,
) -> list[bool]:
@@ -295,6 +393,39 @@ class AsyncHttpx:
tasks.clear()
return result_
@classmethod
async def get_fastest_mirror(cls, url_list: list[str]) -> list[str]:
assert url_list
async def head_mirror(client: type[AsyncHttpx], url: str) -> dict[str, Any]:
begin_time = time.time()
response = await client.head(url=url, timeout=6)
elapsed_time = (time.time() - begin_time) * 1000
content_length = int(response.headers.get("content-length", 0))
return {
"url": url,
"elapsed_time": elapsed_time,
"content_length": content_length,
}
logger.debug(f"开始获取最快镜像,可能需要一段时间... | URL列表:{url_list}")
results = await asyncio.gather(
*(head_mirror(cls, url) for url in url_list),
return_exceptions=True,
)
_results: list[dict[str, Any]] = []
for result in results:
if isinstance(result, BaseException):
logger.warning(f"获取镜像失败,错误:{result}")
else:
logger.debug(f"获取镜像成功,结果:{result}")
_results.append(result)
_results = sorted(iter(_results), key=lambda r: r["elapsed_time"])
return [result["url"] for result in _results]
class AsyncPlaywright:
@classmethod
@@ -322,7 +453,7 @@ class AsyncPlaywright:
element: str | list[str],
*,
wait_time: int | None = None,
viewport_size: Dict[str, int] | None = None,
viewport_size: dict[str, int] | None = None,
wait_until: (
Literal["domcontentloaded", "load", "networkidle"] | None
) = "networkidle",
@@ -344,7 +475,7 @@ class AsyncPlaywright:
type_: 保存类型
"""
if viewport_size is None:
viewport_size = dict(width=2560, height=1080)
viewport_size = {"width": 2560, "height": 1080}
if isinstance(path, str):
path = Path(path)
wait_time = wait_time * 1000 if wait_time else None
+10 -10
View File
@@ -1,23 +1,22 @@
import os
import random
import re
import random
from io import BytesIO
from pathlib import Path
from typing import Awaitable, Callable
from collections.abc import Callable, Awaitable
import cv2
import imagehash
from imagehash import ImageHash
from nonebot.utils import is_coroutine_callable
from PIL import Image
from nonebot.utils import is_coroutine_callable
from zhenxun.configs.path_config import IMAGE_PATH, TEMP_PATH
from zhenxun.services.log import logger
from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.configs.path_config import TEMP_PATH, IMAGE_PATH
from ._build_image import BuildImage, ColorAlias
from ._build_mat import BuildMat, MatType
from ._image_template import ImageTemplate, RowStyle
from ._build_mat import MatType, BuildMat # noqa: F401
from ._image_template import RowStyle, ImageTemplate # noqa: F401
# TODO: text2image 长度错误
@@ -192,8 +191,9 @@ async def text2image(
s.strip(), font, font_size, font_color
)
)
height = sum(img.height + 8 for img in image_list) + pw
width += pw
height += ph
# height += ph
A = BuildImage(
width + left_padding,
height + top_padding + 2,
@@ -386,7 +386,7 @@ def get_img_hash(image_file: str | Path) -> str:
with open(image_file, "rb") as fp:
hash_value = imagehash.average_hash(Image.open(fp))
except Exception as e:
logger.warning(f"获取图片Hash出错", "禁言检测", e=e)
logger.warning("获取图片Hash出错", "禁言检测", e=e)
return str(hash_value)
@@ -407,7 +407,7 @@ async def get_download_image_hash(url: str, mark: str) -> str:
img_hash = get_img_hash(TEMP_PATH / f"compare_download_{mark}_img.jpg")
return str(img_hash)
except Exception as e:
logger.warning(f"下载读取图片Hash出错", e=e)
logger.warning("下载读取图片Hash出错", e=e)
return ""
+51 -13
View File
@@ -2,22 +2,38 @@ from io import BytesIO
from pathlib import Path
import nonebot
from pydantic import BaseModel
from nonebot.adapters.onebot.v11 import Message, MessageSegment
from nonebot_plugin_alconna import At, Image, Text, UniMessage
from nonebot_plugin_alconna import At, Text, AtAll, Image, Video, Voice, UniMessage
from zhenxun.configs.config import BotConfig
from zhenxun.services.log import logger
from zhenxun.configs.config import BotConfig
from zhenxun.utils._build_image import BuildImage
driver = nonebot.get_driver()
MESSAGE_TYPE = (
str | int | float | Path | bytes | BytesIO | BuildImage | At | Image | Text
str
| int
| float
| Path
| bytes
| BytesIO
| BuildImage
| At
| AtAll
| Image
| Text
| Voice
| Video
)
class MessageUtils:
class Config(BaseModel):
image_to_bytes: bool = False
class MessageUtils:
@classmethod
def __build_message(cls, msg_list: list[MESSAGE_TYPE]) -> list[Text | Image]:
"""构造消息
@@ -28,20 +44,17 @@ class MessageUtils:
返回:
list[Text | Text]: 构造完成的消息列表
"""
is_bytes = False
try:
is_bytes = driver.config.image_to_bytes in ["True", "true"]
except AttributeError:
pass
config = nonebot.get_plugin_config(Config)
message_list = []
for msg in msg_list:
if isinstance(msg, (Image, Text, At)):
if isinstance(msg, Image | Text | At | AtAll | Video | Voice):
message_list.append(msg)
elif isinstance(msg, (str, int, float)):
elif isinstance(msg, str | int | float):
message_list.append(Text(str(msg)))
elif isinstance(msg, Path):
if msg.exists():
if is_bytes:
if config.image_to_bytes:
logger.debug("图片转为bytes发送", "MessageUtils")
image = BuildImage.open(msg)
message_list.append(Image(raw=image.pic2bytes()))
else:
@@ -120,7 +133,7 @@ class MessageUtils:
forward_data = []
for r_list in msg_list:
s = ""
if isinstance(r_list, (UniMessage, list)):
if isinstance(r_list, UniMessage | list):
for r in r_list:
if isinstance(r, Text):
s += str(r)
@@ -134,3 +147,28 @@ class MessageUtils:
s = str(r_list)
forward_data.append(s)
return cls.custom_forward_msg(forward_data, uni)
@classmethod
def template2alc(cls, msg_list: list[MessageSegment]) -> list:
"""模板转alc
参数:
msg_list: 消息列表
返回:
list: alc模板
"""
forward_data = []
for msg in msg_list:
if isinstance(msg, str):
forward_data.append(Text(msg))
elif msg.type == "at":
if msg.data["qq"] == "0":
forward_data.append(AtAll())
else:
forward_data.append(At(flag="user", target=msg.data["qq"]))
elif msg.type == "image":
forward_data.append(Image(url=msg.data["file"] or msg.data["url"]))
elif msg.type == "text" and msg.data["text"]:
forward_data.append(Text(msg.data["text"]))
return forward_data
+12 -2
View File
@@ -23,7 +23,6 @@ from zhenxun.models.group_console import GroupConsole
class UserData(BaseModel):
name: str
"""昵称"""
card: str | None = None
@@ -41,7 +40,6 @@ class UserData(BaseModel):
class PlatformUtils:
@classmethod
async def ban_user(cls, bot: Bot, user_id: str, group_id: str, duration: int):
"""禁言
@@ -268,6 +266,18 @@ class PlatformUtils:
)
return None
@classmethod
def get_user_avatar_url(cls, user_id: str, platform: str) -> str | None:
"""快捷获取用户头像url
参数:
user_id: 用户id
platform: 平台
"""
if platform == "qq":
return f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=160"
return None
@classmethod
async def get_group_avatar(cls, gid: str, platform: str) -> bytes | None:
"""快捷获取用群头像