634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

4 |
5 |
6 |
7 |
8 | # SoraBot
9 | _✨ 基于 Nonebot2,互通多平台,超可爱的林汐酱 ✨_
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
60 |
61 |
62 |
63 | 文档
64 | ·
65 | 服务列表
66 | ·
67 | 安装文档
68 | ·
69 | 文档打不开?
70 |
71 |
72 | ## 简介
73 |
74 | > [!Note]
75 | > 一切开发旨在学习,请勿用于非法用途
76 |
77 | > [!IMPORTANT]
78 | > 作者升入高中,暂不能经常维护
79 |
80 | 林汐(SoraBot)基于 Nonebot2 开发,互通多平台,以 Sqlite3 作为数据库的功能型机器人
81 |
82 | ## 特性
83 |
84 | - 使用 NoneBot2 进行项目底层构建.
85 | - 使用 go-cqhttp 作为默认协议端.
86 | - 互通 QQ、QQ频道、Telegram 等平台
87 | - 独立ID,更方便管理与互通数据
88 | - 全新的权限系统,不用重启便可自定义 Bot管理员 和 Bot协助者
89 | - Coming soon...
90 |
91 | ## 你可能会问
92 |
93 | **什么是独立ID,它有什么用?**
94 | 独立ID是林汐为每个用户分配的专属ID,通过它,我们便可知晓用户信息、绑定信息、权限等,以便我们更好向用户提供服务
95 |
96 | **全新的权限系统,新在哪里?**
97 | 林汐的权限系统,并没有使用 Nonebot2 所提供的 `SUPERUSER`,而是改为了 `Bot管理员` 和 `Bot协助者`
98 |
99 | ## 配置
100 |
101 | SoraBot 文档:~~[📖这里](bot.netsora.info)~~ 仓库内介绍:[📦这里](https://github.com/netsora/SoraBot/wiki)
102 |
103 | ## 更新日志
104 |
105 | 版本更新请参考[此处](./CHANGELOG.md).
106 |
107 | 小改动请参考以往的 [commit](https://github.com/netsora/SoraBot/commit/master).
108 |
109 | ## 贡献
110 | 如果你喜欢本项目,可以请我[喝杯快乐水](https://afdian.net/@netsora)
111 |
112 | 如果你有想法、有能力,欢迎:
113 |
114 | * [提交 Issue](https://github.com/netsora/SoraBot/issues)
115 | * [提交 Pull request](https://github.com/netsora/SoraBot/pulls)
116 | * [在交流群内反馈](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=kUsNnKC-8F_YnR6VvYGqDiZOmhSi-iw7&authKey=IlG5%2FP1LrCVfniACFmdKRRW1zXq6fto5a43vfAHBqC5dUNztxLRuJnrVou2Q8UgH&noverify=0&group_code=817451732)
117 |
118 | 请参考 [贡献指南](./CONTRIBUTING.md)
119 |
120 | ## 鸣谢
121 |
122 | 感谢以下 开发者 和 Github 项目对 SoraBot 作出的贡献:(排名不分先后)
123 | - [`nonebot/nonebot2`](https://github.com/nonebot/nonebot2):跨平台Python异步机器人框架
124 | - [`A-kirami/KiramiBot`](https://github.com/A-kirami/KiramiBot):简明轻快的聊天机器人应用。
125 | - [`Kyomotoi/ATRI`](https://github.com/Kyomotoi/ATRI):高性能文爱萝卜子
126 | - [`HibiKier/zhenxun_bot`](https://github.com/HibiKier/zhenxun_bot):非常可爱的绪山真寻bot
127 | - [`CMHopeSunshine/LittlePaimon`](https://github.com/CMHopeSunshine/LittlePaimon):原神Q群机器人
128 | - [`nonebot_plugin_saa`](https://github.com/felinae98/nonebot-plugin-send-anything-anywhere):多适配器消息发送支持
129 | - [`nonebot_plugin_alconna`](https://github.com/nonebot/plugin-alconna):强大的 Nonebot2 命令匹配拓展
130 |
131 | ## 许可证
132 |
133 | 本项目使用 AGPLv3.
134 |
135 | 意味着你可以运行本项目,并向你的用户提供服务。除非获得商业授权,否则无论以何种方式修改或者使用代码,都需要开源
136 |
137 | ## 活动
138 |
139 | 
140 |
--------------------------------------------------------------------------------
/bot.py:
--------------------------------------------------------------------------------
1 | from sora import SoraBot
2 |
3 | bot = SoraBot()
4 |
5 | if __name__ == "__main__":
6 | bot.run()
7 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "sorabot"
3 | version = "1.0.0"
4 | description = "基于 NoneBot2 开发,互通多平台,超可爱的林汐酱"
5 | readme = "README.md"
6 | requires-python = ">=3.10"
7 | license = {text = "AGPL-3.0"}
8 | authors = [{name = "Komorebi", email = "mute231010@gmail.com"}]
9 | classifiers = [
10 | "Development Status :: 4 - Beta",
11 | "Framework :: Robot Framework",
12 | "Framework :: Robot Framework :: Library",
13 | "License :: OSI Approved :: GNU Affero General Public License v3",
14 | "Operating System :: OS Independent",
15 | "Programming Language :: Python",
16 | "Programming Language :: Python :: 3",
17 | "Programming Language :: Python :: 3 :: Only",
18 | "Programming Language :: Python :: 3.10",
19 | "Programming Language :: Python :: 3.11",
20 | "Topic :: Software Development",
21 | "Typing :: Typed",
22 | ]
23 | dependencies = [
24 | "nonebot2[fastapi]>=2.3.0",
25 | "tortoise>=0.1.1",
26 | "tortoise-orm>=0.20.0",
27 | "ujson>=5.8.0",
28 | "ruamel-yaml>=0.17.32",
29 | "apscheduler>=3.10.3",
30 | "httpx>=0.24.1",
31 | "aiofiles>=23.2.1",
32 | "retrying>=1.3.4",
33 | "rich>=13.5.2",
34 | "GitPython>=3.1.32",
35 | "nonebot-plugin-send-anything-anywhere>=0.6.1",
36 | "nonebot-plugin-alconna>=0.48.0",
37 | "nonebot-plugin-userinfo>=0.1.3",
38 | "pillow>=10.0.0",
39 | "websockets>=11.0.3",
40 | "pandas>=2.0.3",
41 | "toml>=0.10.2",
42 | "dnspython>=2.5.0",
43 | "psutil>=5.9.8",
44 | "py-cpuinfo>=9.0.0",
45 | ]
46 |
47 | [project.urls]
48 | homepage = "https://bot.netsora.info/"
49 | repository = "https://github.com/netsora/SoraBot"
50 | documentation = "https://bot.netsora.info/"
51 |
52 | [project.optional-dependencies]
53 | adapters = [
54 | "nonebot-adapter-onebot>=2.4.3",
55 | "nonebot-adapter-qq>=1.3.5",
56 | "nonebot-adapter-telegram>=0.1.0b17",
57 | ]
58 |
59 | [tool.pdm.dev-dependencies]
60 | dev = [
61 | "black>=23.7.0",
62 | "ruff>=0.0.284",
63 | "pre-commit>=3.3.3",
64 | "isort>=5.13.2",
65 | ]
66 |
67 | [tool.pdm.scripts]
68 | start = "pdm run bot.py"
69 |
70 | [tool.black]
71 | line-length = 88
72 | target-version = ["py310", "py311"]
73 | include = '\.pyi?$'
74 | extend-exclude = '''
75 | '''
76 |
77 | [tool.isort]
78 | profile = "black"
79 | line_length = 88
80 | length_sort = true
81 | skip_gitignore = true
82 | force_sort_within_sections = true
83 | extra_standard_library = ["typing_extensions"]
84 |
85 |
86 | [tool.ruff]
87 | select = ["E", "W", "F", "UP", "C", "T", "PYI", "PT", "Q"]
88 | ignore = ["E402", "C901"]
89 | line-length = 120
90 | target-version = "py310"
91 |
92 | [tool.ruff.flake8-pytest-style]
93 | fixture-parentheses = false
94 | mark-parentheses = false
95 |
96 | [tool.pyright]
97 | pythonVersion = "3.10"
98 | pythonPlatform = "All"
99 | executionEnvironments = [
100 | { root = "./tests", extraPaths = ["./"] },
101 | { root = "./" },
102 | ]
103 | typeCheckingMode = "basic"
104 | reportShadowedImports = false
105 |
106 | [build-system]
107 | requires = ["pdm-backend"]
108 | build-backend = "pdm.backend"
109 |
--------------------------------------------------------------------------------
/resources/font/ADLaMDisplay-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/resources/font/ADLaMDisplay-Regular.ttf
--------------------------------------------------------------------------------
/resources/font/SpicyRice-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/resources/font/SpicyRice-Regular.ttf
--------------------------------------------------------------------------------
/resources/font/baotu.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/resources/font/baotu.ttf
--------------------------------------------------------------------------------
/resources/image/status/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/resources/image/status/background.png
--------------------------------------------------------------------------------
/resources/image/status/marker.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/resources/image/status/marker.png
--------------------------------------------------------------------------------
/resources/logo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/resources/logo.jpg
--------------------------------------------------------------------------------
/sora/__init__.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | from pathlib import Path
3 | from typing import Any, ClassVar
4 |
5 | import nonebot
6 | from nonebot.plugin import Plugin
7 | from nonebot.drivers import Driver
8 | from nonebot.utils import path_to_module_name
9 | from nonebot.plugin.manager import PluginManager, _managers
10 |
11 | from .log import console
12 | from .hook import install_hook
13 | from .log import logger as logger
14 | from .log import Text, Panel, Columns
15 | from .version import __version__, __metadata__
16 | from .config import bot_config, sora_config, plugin_config
17 |
18 |
19 | class SoraBot:
20 | driver: ClassVar[Driver]
21 |
22 | def __init__(self) -> None:
23 | self.show_logo()
24 |
25 | if bot_config.debug:
26 | self.print_environment()
27 | console.rule()
28 |
29 | self.init_nonebot(_mixin_config(bot_config.dict()))
30 |
31 | logger.opt(colors=True).success("🚀 SoraBot is initializing...")
32 |
33 | logger.opt(colors=True).debug(
34 | f"Loaded Config: {sora_config.dict()}"
35 | )
36 |
37 | self.load_plugins()
38 |
39 | install_hook()
40 |
41 | logger.opt(colors=True).success("🚀 SoraBot is Running...")
42 |
43 | if bot_config.debug:
44 | console.rule("[blink][yellow]当前处于调试模式中, 请勿在生产环境打开[/][/]")
45 |
46 | def run(self, *args, **kwargs) -> None:
47 | """Sora,启动!"""
48 | self.driver.run(*args, **kwargs)
49 |
50 | def init_nonebot(self, config: dict[str, Any]) -> None:
51 | """初始化 NoneBot"""
52 | nonebot.init(**config)
53 |
54 | self.__class__.driver = nonebot.get_driver()
55 | self.load_adapters(config["adapters"])
56 |
57 | def load_adapters(self, adapters: set[str]) -> None:
58 | """加载适配器"""
59 | adapters = {adapter.replace("~", "nonebot.adapters.") for adapter in adapters}
60 | for adapter in adapters:
61 | module = importlib.import_module(adapter)
62 | self.driver.register_adapter(getattr(module, "Adapter"))
63 |
64 | def load_plugins(self) -> None:
65 | """加载插件"""
66 | plugins = {
67 | path_to_module_name(pp) if (pp := Path(p)).exists() else p
68 | for p in plugin_config.plugins
69 | }
70 | manager = PluginManager(plugins, plugin_config.plugin_dirs)
71 | plugins = manager.available_plugins
72 | _managers.append(manager)
73 |
74 | if plugin_config.whitelist:
75 | plugins &= plugin_config.whitelist
76 |
77 | if plugin_config.blacklist:
78 | plugins -= plugin_config.blacklist
79 |
80 | loaded_plugins = set(
81 | filter(None, (manager.load_plugin(name) for name in plugins))
82 | )
83 |
84 | self.loading_state(loaded_plugins)
85 |
86 | def loading_state(self, plugins: set[Plugin]) -> None:
87 | """打印插件加载状态"""
88 | if loaded_plugins := nonebot.get_loaded_plugins():
89 | logger.opt(colors=True).info(
90 | f"✅ [magenta]Total {len(loaded_plugins)} plugin are successfully loaded.[/]"
91 | )
92 |
93 | if failed_plugins := plugins - loaded_plugins:
94 | logger.opt(colors=True).error(
95 | f"❌ [magenta]Total {len(failed_plugins)} plugin are failed loaded.[/]: {', '.join(plugin.name for plugin in failed_plugins)}" # noqa: E501
96 | )
97 |
98 | def show_logo(self) -> None:
99 | """打印 LOGO"""
100 | console.print(
101 | Columns(
102 | [Text(LOGO.lstrip("\n"), style="bold blue")],
103 | align="center",
104 | expand=True,
105 | )
106 | )
107 |
108 | def print_environment(self) -> None:
109 | """打印环境信息"""
110 | import platform
111 |
112 | environment_info = {
113 | "OS": platform.system(),
114 | "Arch": platform.machine(),
115 | "Python": platform.python_version(),
116 | "SoraBot": __version__,
117 | "NoneBot": nonebot.__version__,
118 | }
119 |
120 | renderables = [
121 | Panel(
122 | Text(justify="center")
123 | .append(k, style="bold")
124 | .append(f"\n{v}", style="yellow"),
125 | expand=True,
126 | width=console.size.width // 6,
127 | )
128 | for k, v in environment_info.items()
129 | ]
130 | console.print(
131 | Columns(
132 | renderables,
133 | align="center",
134 | title="Environment Info",
135 | expand=True,
136 | equal=True,
137 | )
138 | )
139 |
140 |
141 | def get_driver() -> Driver:
142 | """
143 | 获取全局 `Driver` 实例。
144 | """
145 | if SoraBot.driver is None:
146 | raise ValueError("SoraBot has not been initialized.")
147 | return SoraBot.driver
148 |
149 |
150 | def _mixin_config(config: dict[str, Any]) -> dict[str, Any]:
151 | if config["debug"]:
152 | config |= {
153 | "fastapi_openapi_url": config.get("fastapi_openapi_url", "/openapi.json"),
154 | "fastapi_docs_url": config.get("fastapi_docs_url", "/docs"),
155 | "fastapi_redoc_url": config.get("fastapi_redoc_url", "/redoc"),
156 | }
157 | config["fastapi_extra"] = {
158 | "title": __metadata__.name,
159 | "version": __metadata__.version,
160 | "description": __metadata__.summary,
161 | }
162 |
163 | return config
164 |
165 |
166 | LOGO = r"""
167 | _____ ____ __
168 | / ___/____ _________ _ / __ )____ / /_
169 | \__ \/ __ \/ ___/ __ `/ / __ / __ \/ __/
170 | ___/ / /_/ / / / /_/ / / /_/ / /_/ / /_
171 | /____/\____/_/ \__,_/ /_____/\____/\__/
172 | """
173 |
--------------------------------------------------------------------------------
/sora/config/__init__.py:
--------------------------------------------------------------------------------
1 | from .config import SoraConfig
2 | from .utils import load_config
3 | from .path import BOT_DIR as BOT_DIR
4 | from .path import LOG_DIR as LOG_DIR
5 | from .path import RES_DIR as RES_DIR
6 | from .path import DATA_DIR as DATA_DIR
7 | from .path import FONT_DIR as FONT_DIR
8 | from .path import PAGE_DIR as PAGE_DIR
9 | from .path import AUDIO_DIR as AUDIO_DIR
10 | from .path import IMAGE_DIR as IMAGE_DIR
11 | from .path import VIDEO_DIR as VIDEO_DIR
12 | from .config import BaseConfig as BaseConfig
13 |
14 | sora_config = SoraConfig(**load_config())
15 | """SoraBot 配置"""
16 |
17 | bot_config = sora_config.bot
18 | """本体主要配置"""
19 |
20 | plugin_config = sora_config.plugin
21 | """插件加载相关配置"""
22 |
23 | log_config = sora_config.log
24 | """日志相关配置"""
25 |
26 | database_config = sora_config.database
27 | """数据库相关配置"""
28 |
--------------------------------------------------------------------------------
/sora/config/config.py:
--------------------------------------------------------------------------------
1 | """本模块定义了 Sora 运行所需的配置项"""
2 |
3 | from datetime import timedelta
4 | from ipaddress import IPv4Address
5 | from types import MappingProxyType
6 | from typing_extensions import Self
7 | from collections.abc import Mapping, KeysView
8 | from typing import TYPE_CHECKING, Any, Literal, ClassVar, NoReturn, TypeAlias
9 |
10 | from nonebot.config import Env, Config
11 | from pydantic import Field, BaseModel, IPvAnyAddress, model_validator
12 |
13 | from .utils import find_plugin
14 |
15 | LevelName: TypeAlias = Literal[
16 | "TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL"
17 | ]
18 |
19 |
20 | class BaseConfig(BaseModel, Mapping):
21 | __raw_config__: ClassVar[MappingProxyType[str, Any]] = MappingProxyType({})
22 |
23 | def __getitem__(self, key: str) -> Any:
24 | try:
25 | return getattr(self, key)
26 | except AttributeError as e:
27 | raise RuntimeError(
28 | f"{self.__class__.__name__} 不存在 {key} 配置, 请检查拼写是否正确"
29 | ) from e
30 |
31 | def __setitem__(self, *_) -> NoReturn:
32 | raise RuntimeError("无法在运行时修改配置")
33 |
34 | def __delitem__(self, _) -> NoReturn:
35 | raise RuntimeError("无法在运行时修改配置")
36 |
37 | def __setattr__(self, *_) -> NoReturn:
38 | raise RuntimeError("无法在运行时修改配置")
39 |
40 | def __delattr__(self, _) -> NoReturn:
41 | raise RuntimeError("无法在运行时修改配置")
42 |
43 | def __len__(self) -> int:
44 | return len(self.__dict__)
45 |
46 | def __repr__(self) -> str:
47 | return f"<{self.__class__.__name__} {self.__dict__}>"
48 |
49 | def keys(self) -> KeysView[str]:
50 | return self.__dict__.keys()
51 |
52 | @classmethod
53 | def load_config(cls, namespace: str | None = None) -> Self:
54 | """加载配置。
55 |
56 | ### 参数
57 | namespace: 配置的命名空间,如果为 None 则自动查找并使用插件名称
58 | """
59 | if namespace is None and (plugin := find_plugin(cls)):
60 | namespace = plugin.name
61 |
62 | if not namespace:
63 | raise RuntimeError("无法确定配置所属插件,请指定 namespace")
64 |
65 | return cls(**cls.__raw_config__.get(namespace, {}))
66 |
67 |
68 | class LogConfig(BaseConfig):
69 | log_expire_timeout: int = 7
70 | """日志文件过期时间"""
71 |
72 |
73 | class DatabaseConfig(BaseConfig):
74 | """
75 | Sqlite 数据库配置
76 | """
77 |
78 | url: str = "sqlite://db.sqlite3"
79 | """Sqlite 连接 URL"""
80 |
81 | database: str = "sora"
82 | """Sqlite 数据库名称"""
83 |
84 |
85 | class PluginConfig(BaseConfig):
86 | """
87 | 插件加载配置
88 | """
89 |
90 | plugins: set[str] = set()
91 | """加载的插件"""
92 |
93 | plugin_dirs: set[str] = set()
94 | """插件目录列表"""
95 |
96 | whitelist: set[str] | None = None
97 | """插件白名单,只加载指定插件"""
98 |
99 | blacklist: set[str] | None = None
100 | """插件黑名单,不加载指定插件"""
101 |
102 |
103 | class BotConfig(BaseConfig):
104 | """
105 | Bot 主要配置。
106 | """
107 |
108 | driver: str = "~fastapi"
109 | """Sora 运行所使用的 `Driver`"""
110 |
111 | adapters: set[str] = {"~onebot.v11"}
112 | """Sora 所使用的 `Adapter`"""
113 |
114 | host: IPvAnyAddress = IPv4Address("127.0.0.1") # type: ignore
115 | """Sora 的 HTTP 和 WebSocket 服务端监听的 IP/主机名"""
116 |
117 | port: int = Field(default=8120, ge=1, le=65535)
118 | """Sora 的 HTTP 和 WebSocket 服务端监听的端口"""
119 |
120 | debug: bool = False
121 | """是否以调试模式运行 Sora"""
122 |
123 | log_level: LevelName | int = "INFO"
124 | """配置 Sora 日志输出等级,可以为 `int` 类型等级或等级名称,参考 [loguru 日志等级](https://loguru.readthedocs.io/en/stable/api/logger.html#levels)"""
125 |
126 | log_file: LevelName | tuple[LevelName] = "ERROR"
127 | """Sora 的日志保存等级,必须为等级名称"""
128 |
129 | api_root: dict[str, str] = {}
130 | """以机器人 ID 为键,上报地址为值的字典"""
131 |
132 | api_timeout: float = 30.0
133 | """API 请求超时时间,单位: 秒"""
134 |
135 | onebot_access_token: str = Field(default=None, alias="access_token")
136 | """API 请求以及上报所需密钥,在请求头中携带"""
137 |
138 | secret: str | None = None
139 | """HTTP POST 形式上报所需签名,在请求头中携带"""
140 |
141 | nickname: set[str] = {"林汐", "Sora"}
142 | """机器人昵称"""
143 |
144 | command_start: set[str] = {"/"}
145 | """命令的起始标记,用于判断一条消息是不是命令"""
146 |
147 | command_sep: set[str] = {"."}
148 | """命令的分隔标记,用于将文本形式的命令切分为元组(实际的命令名)"""
149 |
150 | session_expire_timeout: timedelta = timedelta(minutes=2)
151 | """等待用户回复的超时时间"""
152 |
153 | proxy_url: str | None = None
154 | """HTTP 代理地址"""
155 |
156 | http_timeout: float = 10.0
157 | """HTTP 请求超时时间,单位: 秒"""
158 |
159 | browser: Literal["chromium", "firefox", "webkit"] = "chromium"
160 | """浏览器类型"""
161 |
162 | time_zone: str = "Asia/Shanghai"
163 | """时区"""
164 |
165 | default_policy_allow: set[str] = {"*"}
166 | """默认权限策略允许的内容列表"""
167 |
168 | env_file: str | None = Field(default=None, alias="_env_file")
169 | """配置文件名默认从 `.env.{env_name}` 中读取配置"""
170 |
171 | @model_validator(mode="before")
172 | def mixin_config(cls, values: dict[str, Any]) -> dict[str, Any]:
173 | config = Config(**values, _env_file=(".env", f".env.{Env().environment}"))
174 | return config.model_dump(exclude_unset=True)
175 |
176 | class Config:
177 | extra = "allow"
178 |
179 | if TYPE_CHECKING:
180 |
181 | def __getattr__(self, name: str) -> Any:
182 | ...
183 |
184 |
185 | class SoraConfig(BaseConfig):
186 | """
187 | Sora 主要配置。
188 | """
189 |
190 | bot: BotConfig
191 | """本体主要配置"""
192 |
193 | plugin: PluginConfig
194 | """插件加载相关配置"""
195 |
196 | log: LogConfig
197 | """日志相关配置"""
198 |
199 | database: DatabaseConfig
200 | """数据库相关配置"""
201 |
202 | @model_validator(mode="before")
203 | def set_default_config(cls, values: dict[str, Any]) -> dict[str, Any]:
204 | BaseConfig.__raw_config__ = MappingProxyType(values)
205 | for name, config in cls.__annotations__.items():
206 | values.setdefault(name, config())
207 | return values
208 |
209 | @property
210 | def config(self) -> MappingProxyType[str, Any]:
211 | """原始配置"""
212 | return BaseConfig.__raw_config__
213 |
--------------------------------------------------------------------------------
/sora/config/path.py:
--------------------------------------------------------------------------------
1 | """本模块定义了 Sora 运行所需的文件目录"""
2 |
3 | from pathlib import Path
4 |
5 | BOT_DIR = Path.cwd()
6 | """Bot 根目录"""
7 |
8 | DATA_DIR = BOT_DIR / "data"
9 | """数据保存目录"""
10 |
11 | RES_DIR = BOT_DIR / "resources"
12 | """资源文件目录"""
13 |
14 | LOG_DIR = DATA_DIR / "logs"
15 | """日志保存目录"""
16 |
17 | IMAGE_DIR = RES_DIR / "image"
18 | """图片文件目录"""
19 |
20 | VIDEO_DIR = RES_DIR / "video"
21 | """视频文件目录"""
22 |
23 | AUDIO_DIR = RES_DIR / "audio"
24 | """音频文件目录"""
25 |
26 | FONT_DIR = RES_DIR / "font"
27 | """字体文件目录"""
28 |
29 | PAGE_DIR = RES_DIR / "page"
30 | """网页文件目录"""
31 |
32 | for name, var in locals().copy().items():
33 | if name.endswith("_DIR") and isinstance(var, Path):
34 | var.mkdir(parents=True, exist_ok=True)
35 |
--------------------------------------------------------------------------------
/sora/config/utils.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | import itertools
3 | from typing import Any
4 | from pathlib import Path
5 | from collections.abc import Callable
6 |
7 | from nonebot.plugin import Plugin
8 |
9 | try: # pragma: py-gte-311
10 | import tomllib # pyright: ignore[reportMissingImports]
11 | except ModuleNotFoundError: # pragma: py-lt-311
12 | import tomli as tomllib
13 |
14 |
15 | def load_config() -> dict[str, Any]:
16 | """
17 | 加载配置。
18 | """
19 |
20 | def load_file(path: str | Path) -> dict[str, Any]:
21 | return tomllib.loads(Path(path).read_text(encoding="utf-8"))
22 |
23 | file_name = ("sora", "sora.config")
24 | file_type = ("toml", "yaml", "yml", "json")
25 | config_files = itertools.product(file_name, file_type)
26 | for name, type in config_files:
27 | file = Path(f"{name}.{type}")
28 | if file.is_file():
29 | return load_file(file)
30 | pyproject = load_file("pyproject.toml")
31 | return pyproject.get("tool", {}).get("sora", {})
32 |
33 |
34 | def find_plugin(cls: Callable[..., Any], /) -> Plugin | None:
35 | """查找类所在的插件对象
36 |
37 | ### 参数
38 | cls: 查找的类
39 | """
40 | module_name = cls.__module__
41 | module = importlib.import_module(module_name)
42 | parts = module_name.split(".")
43 |
44 | for i in range(len(parts), 0, -1):
45 | current_module = ".".join(parts[:i])
46 | module = importlib.import_module(current_module)
47 | if plugin := getattr(module, "__plugin__", None):
48 | return plugin
49 |
50 | return None
51 |
--------------------------------------------------------------------------------
/sora/database/__init__.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from tortoise import Tortoise
4 |
5 | from sora.log import logger
6 | from sora.config import database_config
7 | from sora.hook import on_startup, on_shutdown
8 |
9 | from .models import Ban as Ban
10 | from .models import Bind as Bind
11 | from .models import Sign as Sign
12 | from .models import User as User
13 |
14 | DATABASE: dict[str, Any] = {"models": ["sora.database.models"]}
15 |
16 |
17 | @on_startup(pre=True)
18 | async def connect_database() -> None:
19 | """
20 | 建立数据库连接
21 | """
22 | try:
23 | await Tortoise.init(
24 | db_url=database_config.url, modules=DATABASE, timezone="Asia/Shanghai"
25 | )
26 | await Tortoise.generate_schemas()
27 | logger.opt(colors=True).success("🗃️ [magenta]Database connected successful.[/]")
28 |
29 | except Exception as e:
30 | raise Exception("Database connection failed.") from e
31 |
32 |
33 | def register_database(model: str):
34 | """
35 | 注册数据库
36 | """
37 | models: list[str] = DATABASE["models"]
38 | models.append(model)
39 |
40 |
41 | @on_shutdown
42 | async def disconnect_database() -> None:
43 | """
44 | 断开数据库连接
45 | """
46 | await Tortoise.close_connections()
47 | logger.opt(colors=True).success("🗃️ [magenta]Database disconnected successful.[/]")
48 |
49 |
50 | # @scheduler.scheduled_job("cron", hour=0, minute=0, misfire_grace_time=10)
51 | # async def daily_reset():
52 | # """
53 | # 重置数据库相关设置
54 | # """
55 | # ...
56 |
--------------------------------------------------------------------------------
/sora/database/models/__init__.py:
--------------------------------------------------------------------------------
1 | from .ban import Ban as Ban
2 | from .bind import Bind as Bind
3 | from .sign import Sign as Sign
4 | from .user import User as User
5 |
--------------------------------------------------------------------------------
/sora/database/models/ban.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | from tortoise import fields
4 | from tortoise.models import Model
5 |
6 | from sora.log import logger
7 |
8 |
9 | class Ban(Model):
10 | id = fields.IntField(pk=True, generated=True, auto_increment=True)
11 | uid = fields.CharField(max_length=10, unique=True)
12 | """用户ID"""
13 | ban_time = fields.BigIntField()
14 | """ban开始的时间"""
15 | duration = fields.BigIntField()
16 | """ban时长"""
17 |
18 | class Meta:
19 | table = "Ban"
20 | table_description = "封禁人员数据表"
21 |
22 | @classmethod
23 | async def check_ban_time(cls, uid: str) -> int | str:
24 | """
25 | 说明:
26 | 检测用户被ban时长
27 | 参数:
28 | * uid: 用户id
29 | """
30 | if user := await cls.filter(uid=uid).first():
31 | if (
32 | time.time() - (user.ban_time + user.duration) > 0
33 | and user.duration != -1
34 | ):
35 | return ""
36 | if user.duration == -1:
37 | return "∞"
38 | return int(time.time() - user.ban_time - user.duration)
39 | return ""
40 |
41 | @classmethod
42 | async def is_banned(cls, uid: str) -> bool:
43 | """
44 | 说明:
45 | 判断用户是否被ban
46 | 参数:
47 | * uid: 用户id
48 | """
49 | if await cls.check_ban_time(uid):
50 | return True
51 | else:
52 | await cls.unban(uid)
53 | return False
54 |
55 | @classmethod
56 | async def ban(cls, uid: str, duration: int) -> None:
57 | """
58 | 说明:
59 | ban掉目标用户
60 | 参数:
61 | * uid: 目标用户id
62 | * duration: ban时长(秒),-1 表示永久封禁
63 | """
64 | logger.debug("封禁", f"封禁用户,ID: {uid},时长: {duration}")
65 | if await cls.filter(uid=uid).first():
66 | await cls.unban(uid)
67 | await cls.create(
68 | uid=uid,
69 | ban_time=time.time(),
70 | duration=duration,
71 | )
72 |
73 | @classmethod
74 | async def unban(cls, uid: str) -> bool:
75 | """
76 | 说明:
77 | unban用户
78 | 参数:
79 | * uid: 用户id
80 | """
81 | if user := await cls.filter(uid=uid).first():
82 | logger.debug("封禁", f"解除封禁:{uid}")
83 | await user.delete()
84 | return True
85 | return False
86 |
--------------------------------------------------------------------------------
/sora/database/models/bind.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from tortoise import fields
4 | from tortoise.models import Model
5 |
6 | from .user import User
7 |
8 |
9 | class Bind(Model):
10 | id = fields.IntField(pk=True, generated=True, auto_increment=True)
11 | uid = fields.CharField(max_length=10)
12 | """用户ID"""
13 | origin_uid = fields.CharField(max_length=10, null=True)
14 | """用户原始ID"""
15 | platform = fields.CharField(max_length=50)
16 | """绑定平台"""
17 | pid = fields.CharField(max_length=50)
18 | """平台ID"""
19 |
20 | info: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
21 | "models.User", related_name="bind", to_field="uid"
22 | )
23 |
24 | class Meta:
25 | table = "Bind"
26 |
27 | @classmethod
28 | async def check_pid_exists(cls, platform: str, pid: str) -> bool:
29 | """
30 | 说明:
31 | 判断 platform 账号 是否存在于 Bind 表中
32 |
33 | 参数:
34 | * platform: 平台
35 | * pid: 平台ID
36 |
37 | 返回:
38 | 如果存在则返回 `true`,
39 | 反之则返回 `false`
40 | """
41 | exists = await cls.filter(platform=platform, pid=pid).exists()
42 | return exists
43 |
44 | @classmethod
45 | async def bind(cls, origin_uid: str, bind_uid: str) -> None:
46 | """
47 | 说明:
48 | 绑定账号
49 | 参数:
50 | * origin_uid: 原 uid
51 | * bind_uid: 预绑定后的 uid
52 | """
53 | bind = await Bind.get(uid=origin_uid)
54 | user = await User.get(uid=bind_uid)
55 | bind.origin_uid = origin_uid
56 | bind.uid = bind_uid
57 | bind.info = user
58 | await bind.save()
59 |
60 | @classmethod
61 | async def cancel(cls, origin_uid: str, rebind_uid: str, platform: str) -> None:
62 | """
63 | 说明:
64 | 取消绑定
65 | 参数:
66 | * origin_uid: 原uid
67 | * rebind_uid: 预取消绑定的 uid
68 | """
69 | bind = await Bind.get(uid=rebind_uid, platform=platform)
70 | user = await User.get(uid=origin_uid)
71 | bind.origin_uid = None
72 | bind.uid = origin_uid
73 | bind.info = user
74 | await bind.save()
75 |
76 | @classmethod
77 | async def get_user_bind(cls, pid: str) -> list[dict[str, Any]]:
78 | """
79 | 说明:
80 | 获取用户绑定信息
81 | 参数:
82 | * pid: 平台ID
83 | """
84 | user_data = await cls.filter(pid=pid).values("uid", "platform", "pid")
85 | uid = user_data[0]["uid"]
86 | data = await cls.filter(uid=uid).values("uid", "platform", "pid")
87 | return data
88 |
--------------------------------------------------------------------------------
/sora/database/models/item.py:
--------------------------------------------------------------------------------
1 | from tortoise import fields
2 | from tortoise.models import Model
3 |
4 |
5 | class Item(Model):
6 | id = fields.IntField(pk=True, generated=True, auto_increment=True)
7 | """物品 ID"""
8 | name = fields.CharField(max_length=20)
9 | """物品名称"""
10 | description = fields.TextField(null=True, default="神秘")
11 | """物品介绍"""
12 | amount = fields.BigIntField()
13 | """物品数量"""
14 |
15 | class Meta:
16 | table = "Item"
17 |
--------------------------------------------------------------------------------
/sora/database/models/sign.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from tortoise import fields
4 | from tortoise.models import Model
5 |
6 |
7 | class Sign(Model):
8 | id = fields.IntField(pk=True, generated=True, auto_increment=True)
9 | uid = fields.CharField(max_length=10)
10 | """用户 ID"""
11 | total_days = fields.IntField(default=0)
12 | """累计签到天数"""
13 | continuous_days = fields.IntField(default=0)
14 | """连续签到天数"""
15 | last_sign = fields.DatetimeField(null=True, alias="last_day")
16 | """上次签到日期"""
17 |
18 | class Meta:
19 | table = "Sign"
20 |
21 | @classmethod
22 | async def get_today_rank(cls, limit: int = 10) -> list:
23 | """
24 | 说明:
25 | 获取当日签到排名前十的用户
26 | 参数:
27 | * limit: 排名限制(默认排名 top10)
28 | """
29 | today = datetime.today()
30 | return await cls.filter(last_sign=today).order_by("-total_days").limit(limit)
31 |
32 | @classmethod
33 | async def get_total_rank(cls, limit: int = 10) -> list:
34 | """
35 | 说明:
36 | 获取签到总排名
37 | 参数:
38 | * user_id
39 | * limit: 排名限制(默认排名 top10)
40 | """
41 | return (
42 | await cls.filter(last_sign__isnull=False)
43 | .order_by("-total_days")
44 | .limit(limit)
45 | )
46 |
47 | @classmethod
48 | async def get_sign_info(cls, uid: str):
49 | """
50 | 说明:
51 | 获取用户签到信息
52 | 参数:
53 | * uid
54 | """
55 | return await cls.get_or_none(uid=uid)
56 |
--------------------------------------------------------------------------------
/sora/database/models/user.py:
--------------------------------------------------------------------------------
1 | import random
2 | from datetime import date
3 | from typing import TYPE_CHECKING, Any
4 |
5 | from tortoise import fields
6 | from tortoise.models import Model
7 | from nonebot.internal.adapter import Event
8 | from nonebot.internal.permission import Permission as Permission
9 |
10 | if TYPE_CHECKING:
11 | from .bind import Bind
12 |
13 |
14 | class User(Model):
15 | id = fields.IntField(pk=True, generated=True, auto_increment=True)
16 | uid = fields.CharField(max_length=10, unique=True)
17 | """指用户注册后 Sora 为其分配的ID"""
18 | user_name = fields.CharField(max_length=15, unique=True)
19 | """用户昵称"""
20 | level = fields.IntField(default=0)
21 | """等级"""
22 | exp = fields.IntField(default=0)
23 | """经验值"""
24 | coin = fields.IntField(default=0)
25 | """硬币数"""
26 | favor = fields.IntField(default=0)
27 | """好感度"""
28 | birthday = fields.DateField(null=True)
29 | """生日"""
30 | register_time = fields.DatetimeField(auto_now_add=True)
31 | """注册日期"""
32 | permission = fields.CharField(max_length=10)
33 |
34 | bind: fields.ReverseRelation["Bind"]
35 |
36 | class Meta:
37 | table = "User"
38 |
39 | @classmethod
40 | async def create_user(
41 | cls,
42 | uid: str,
43 | user_name: str,
44 | level: int = 1,
45 | exp: int = 0,
46 | coin: int = 50,
47 | favor: int = 0,
48 | birthday: date | None = None,
49 | permission: str = "NORMAL",
50 | ):
51 | """
52 | 说明:
53 | 创建用户账号
54 | 参数:
55 | * uid: 用户ID
56 | * user_name: 用户名
57 | * level: 等级
58 | * exp: 经验值
59 | * coin: 硬币
60 | * favor: 好感度
61 | * birthday: 出生日期,默认为 None
62 | * permission: 权限
63 | """
64 | return await cls.create(
65 | uid=uid,
66 | user_name=user_name,
67 | level=level,
68 | exp=exp,
69 | coin=coin,
70 | favor=favor,
71 | birthday=birthday,
72 | permission=permission,
73 | )
74 |
75 | @classmethod
76 | async def delete_user(cls, uid: str) -> None:
77 | """
78 | 说明:
79 | 删除用户账号及数据
80 | 参数:
81 | * uid: 用户ID
82 | """
83 | user = await cls.get(uid=uid)
84 | await user.delete()
85 |
86 | @classmethod
87 | async def reward(
88 | cls, uid: str, *, reward: dict[str, Any]
89 | ) -> dict[str, int | float]:
90 | """
91 | 说明:
92 | 奖励用户
93 | 参数:
94 | * reward: 奖励
95 | 返回:
96 | 奖励的数量
97 | """
98 | user = await cls.get(uid=uid)
99 | reward_amount = {}
100 | for key, value in reward.items():
101 | match key:
102 | case "coin":
103 | if isinstance(value, list):
104 | amount = random.randint(value[0], value[1])
105 | elif isinstance(value, int):
106 | amount = value
107 | else:
108 | raise TypeError(f"Invalid reward type: {type(value)}")
109 | user.coin += amount
110 | reward_amount[key] = amount
111 | case "exp":
112 | if isinstance(value, list):
113 | amount = random.randint(value[0], value[1])
114 | elif isinstance(value, int):
115 | amount = value
116 | else:
117 | raise TypeError(f"Invalid reward type: {type(value)}")
118 | user.exp += amount
119 | reward_amount[key] = amount
120 | case "favor":
121 | if isinstance(value, list):
122 | amount = random.randint(value[0], value[1])
123 | elif isinstance(value, float | int):
124 | amount = float(value)
125 | else:
126 | raise TypeError(f"Invalid reward type: {type(value)}")
127 | user.favor += amount
128 | reward_amount[key] = amount
129 | case _:
130 | raise ValueError(f"Invalid reward key: {key}")
131 | await user.save()
132 | return reward_amount
133 |
134 | @classmethod
135 | async def check_username(cls, user_name: str) -> bool:
136 | """
137 | 说明:
138 | 判断数据表中是否有相同的 user_name
139 | 参数:
140 | *user_name: 用户名
141 | """
142 | return await cls.exists(user_name=user_name)
143 |
144 | @classmethod
145 | async def check_exists(cls, platform: str, pid: str):
146 | """
147 | 判断用户是否存在
148 | """
149 | return await cls.filter(bind__platform=platform, bind__pid=pid).exists()
150 |
151 | @classmethod
152 | async def get_user_by_uid(cls, uid: str):
153 | return await User.get(uid=uid)
154 |
155 | @classmethod
156 | async def get_user_by_pid(cls, pid: str):
157 | return await User.get(bind__pid=pid)
158 |
159 | @classmethod
160 | async def get_user_by_event(cls, event: Event):
161 | return await User.get(bind__pid=event.get_user_id())
162 |
--------------------------------------------------------------------------------
/sora/hook.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 | from collections import defaultdict
3 | from collections.abc import Callable
4 | from typing import Any, TypeVar, ParamSpec, cast
5 |
6 | from nonebot import get_driver
7 | from nonebot.typing import T_BotConnectionHook, T_BotDisconnectionHook
8 |
9 | R = TypeVar("R")
10 | P = ParamSpec("P")
11 |
12 | AnyCallable = Callable[..., Any]
13 |
14 | _backlog_hooks = defaultdict(list)
15 |
16 | _hook_installed = False
17 |
18 |
19 | def backlog_hook(hook: Callable[P, R]) -> Callable[P, R]:
20 | """在初始化之前暂存钩子"""
21 |
22 | @wraps(hook)
23 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
24 | if _hook_installed:
25 | return hook(*args, **kwargs)
26 | try:
27 | _backlog_hooks[hook].append(args[0])
28 | return cast(R, args[0])
29 | except IndexError:
30 | return hook(*args, **kwargs)
31 |
32 | return wrapper
33 |
34 |
35 | def install_hook() -> None:
36 | """将暂存的钩子安装"""
37 | for hook, funcs in _backlog_hooks.items():
38 | while funcs:
39 | func = funcs.pop(0)
40 | hook(func)
41 | global _hook_installed # noqa: PLW0603
42 | _hook_installed = True
43 |
44 |
45 | @backlog_hook
46 | def on_startup(func: AnyCallable | None = None, pre: bool = False) -> AnyCallable:
47 | """在 `SoraBot` 启动时有序执行"""
48 | if func is None:
49 | return lambda f: on_startup(f, pre=pre)
50 | if pre:
51 | _backlog_hooks[on_startup.__wrapped__].insert(0, func)
52 | return func
53 | return get_driver().on_startup(func)
54 |
55 |
56 | @backlog_hook
57 | def on_shutdown(func: AnyCallable) -> AnyCallable:
58 | """在 `SoraBot` 停止时有序执行"""
59 | return get_driver().on_shutdown(func)
60 |
61 |
62 | @backlog_hook
63 | def on_bot_connect(func: T_BotConnectionHook) -> T_BotConnectionHook:
64 | """
65 | 在 bot 成功连接到 `SoraBot` 时执行。
66 | """
67 | return get_driver().on_bot_connect(func)
68 |
69 |
70 | @backlog_hook
71 | def on_bot_disconnect(func: T_BotDisconnectionHook) -> T_BotDisconnectionHook:
72 | """
73 | 在 bot 与 `SoraBot` 连接断开时执行。
74 | """
75 | return get_driver().on_bot_disconnect(func)
76 |
--------------------------------------------------------------------------------
/sora/log.py:
--------------------------------------------------------------------------------
1 | import re
2 | import logging
3 | import builtins
4 | from functools import wraps
5 | from collections.abc import Callable
6 | from typing import TYPE_CHECKING, Any, Literal, ClassVar, TypeAlias, get_args
7 |
8 | import rich
9 | import loguru
10 | import nonebot
11 | from rich.theme import Theme
12 | from rich.markup import escape
13 | from rich.console import Console
14 | from rich.text import Text as Text
15 | from rich.traceback import install
16 | from loguru._handler import Message
17 | from rich.logging import RichHandler
18 | from rich.panel import Panel as Panel
19 | from rich.table import Table as Table
20 | from loguru._file_sink import FileSink
21 | from loguru._logger import Core, Logger
22 | from rich.columns import Columns as Columns
23 | from rich.markdown import Markdown as Markdown
24 | from rich.progress import Progress as Progress
25 | from nonebot.log import LoguruHandler as LoguruHandler
26 |
27 | from .config import LOG_DIR, bot_config, log_config
28 |
29 | if TYPE_CHECKING:
30 | from loguru import Record
31 | from loguru import Logger as LoggerType
32 |
33 | logger: "LoggerType" = loguru.logger
34 |
35 | LevelName: TypeAlias = Literal[
36 | "TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL"
37 | ]
38 |
39 | suppress = () if bot_config.debug else (nonebot,)
40 |
41 | install(width=None, suppress=suppress, show_locals=bot_config.debug)
42 |
43 | builtins.print = rich.print
44 |
45 | for lv in Core().levels.values():
46 | logging.addLevelName(lv.no, lv.name)
47 |
48 |
49 | color_dict = {
50 | "k": "black",
51 | "e": "blue",
52 | "c": "cyan",
53 | "g": "green",
54 | "m": "magenta",
55 | "r": "red",
56 | "w": "white",
57 | "y": "yellow",
58 | }
59 |
60 | style_dict = {
61 | "b": "bold",
62 | "d": "dim",
63 | "n": "normal",
64 | "i": "italic",
65 | "u": "underline",
66 | "s": "strike",
67 | "v": "reverse",
68 | "l": "blink",
69 | "h": "hide",
70 | }
71 |
72 |
73 | def tag_convert(match: re.Match[str]) -> str:
74 | """loguru 标签转写 rich 标签"""
75 |
76 | def get_color(color: str) -> str:
77 | if color.isnumeric():
78 | return f"color({color})"
79 | return f"rgb({color})" if "," in color else color
80 |
81 | markup: str = match[0]
82 | tag: str = match[1]
83 | is_forecolor = tag.islower()
84 | tag = tag.lower()
85 | is_brightcolor = tag.startswith("light")
86 | tag = tag.removeprefix("light-")
87 |
88 | if len(tag) == 2: # noqa: PLR2004
89 | tag = tag[:-1]
90 |
91 | match tag.split(" "):
92 | case [abbr] if abbr in color_dict:
93 | color = color_dict.get(abbr, "")
94 | if is_brightcolor:
95 | color = f"bright_{color}"
96 | _type = color if is_forecolor else f"on {color}"
97 | case [abbr] if abbr in style_dict:
98 | _type = style_dict.get(abbr, "")
99 | case ["fg", color]:
100 | _type = get_color(color)
101 | case ["bg", color]:
102 | _type = f"on {get_color(color)}"
103 | case _:
104 | _type = tag
105 |
106 | return markup.replace("<", "[").replace(">", "]").replace(match[1], _type)
107 |
108 |
109 | def handle_log(func) -> Callable[..., None]:
110 | """将 loguru 的样式标记转换为 rich 的样式标记"""
111 |
112 | @wraps(func)
113 | def wrapper(
114 | self,
115 | level,
116 | from_decorator,
117 | options,
118 | message,
119 | args,
120 | kwargs,
121 | ) -> None:
122 | (exception, depth, record, lazy, colors, *_, extra) = options
123 | if colors:
124 | extra["colorize"] = True
125 | message = re.compile(r"(?\s]*)>").sub(
126 | tag_convert, message
127 | )
128 | else:
129 | message = escape(message)
130 | options = (exception, depth + 1, record, lazy, False, *_, extra)
131 | return func(
132 | self,
133 | level,
134 | from_decorator,
135 | options,
136 | message,
137 | args,
138 | kwargs,
139 | )
140 |
141 | return wrapper
142 |
143 |
144 | Logger._log = handle_log(Logger._log)
145 |
146 |
147 | def handle_write(func) -> Callable[..., None]:
148 | """清洗日志中的 rich 样式标记"""
149 |
150 | @wraps(func)
151 | def wrapper(self, message) -> None:
152 | record = message.record
153 | extra = record.get("extra", {})
154 | if extra.get("colorize"):
155 | message = Message(re.sub(r"(\\*)(\[[a-z#/@][^[]*?])", "", message))
156 | message.record = record
157 | return func(self, message)
158 |
159 | return wrapper
160 |
161 |
162 | FileSink.write = handle_write(FileSink.write)
163 |
164 |
165 | custom_theme = Theme(
166 | {
167 | "log.time": "cyan",
168 | "logging.level.debug": "blue",
169 | "logging.level.info": "",
170 | "logging.level.warning": "yellow",
171 | "logging.level.success": "bright_green",
172 | "logging.level.trace": "bright_black",
173 | },
174 | )
175 |
176 | console = Console(theme=custom_theme)
177 |
178 | handler = RichHandler(
179 | console=console,
180 | show_path=False,
181 | omit_repeated_times=False,
182 | markup=True,
183 | rich_tracebacks=True,
184 | tracebacks_show_locals=bot_config.debug,
185 | tracebacks_suppress=suppress,
186 | log_time_format="%m-%d %H:%M:%S",
187 | )
188 |
189 |
190 | class LogFilter:
191 | level: ClassVar[LevelName | int] = (
192 | "DEBUG" if bot_config.debug else bot_config.log_level
193 | )
194 |
195 | def __call__(self, record: "Record") -> bool:
196 | level = record["extra"].get("filter_level") or self.level
197 | levelno = level if isinstance(level, int) else logger.level(level).no
198 | return record["level"].no >= levelno
199 |
200 |
201 | LOG_CONFIG = {
202 | "rotation": "00:00",
203 | "enqueue": True,
204 | "encoding": "utf-8",
205 | "retention": f"{log_config.log_expire_timeout} days",
206 | }
207 |
208 |
209 | def file_handler(levels: LevelName | tuple[LevelName, ...]) -> list[dict[str, Any]]:
210 | if not isinstance(levels, tuple):
211 | level_names = get_args(LevelName)
212 | minimum = level_names.index(levels)
213 | levels = level_names[minimum:]
214 | return [
215 | {
216 | "sink": LOG_DIR / level.lower() / "{time:YYYY-MM-DD}.log",
217 | "level": level,
218 | **LOG_CONFIG,
219 | }
220 | for level in levels
221 | ]
222 |
223 |
224 | logger.remove()
225 | logger.configure(
226 | handlers=[
227 | {
228 | "sink": handler,
229 | "level": 0,
230 | "colorize": False,
231 | "diagnose": False,
232 | "backtrace": True,
233 | "filter": LogFilter(),
234 | "format": lambda _: "[light_slate_blue bold][link={file.path}:{line}]{name}[/][/] [dim]|[/] {message}",
235 | },
236 | *file_handler(bot_config.log_file),
237 | ]
238 | )
239 |
240 |
241 | def new_logger(
242 | name: str, *, filter_level: LevelName | int | None = None
243 | ) -> "LoggerType":
244 | """创建新的日志记录器。
245 |
246 | ### 参数
247 | name: 日志名称
248 |
249 | filter_level: 过滤等级,当日志等级大于过滤等级时才会显示
250 | """
251 | return logger.patch(lambda record: record.update({"name": name})).bind(
252 | filter_level=filter_level
253 | )
254 |
--------------------------------------------------------------------------------
/sora/permission.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from nonebot.internal.adapter.event import Event
4 | from nonebot.internal.permission import Permission as Permission
5 |
6 | from sora.database import User
7 |
8 |
9 | async def get_admin_list() -> list[tuple[Any, ...]]:
10 | """
11 | 获取权限为 `Bot管理员` 的所有用户ID
12 | """
13 | return await User.filter(permission="ADMIN").values_list("uid", flat=True)
14 |
15 |
16 | async def get_helper_list() -> list[tuple[Any, ...]]:
17 | """
18 | 获取所有拥有 `Bot协助者` 权限的用户ID
19 | """
20 | return await User.filter(permission="HELPER").values_list("uid", flat=True)
21 |
22 |
23 | class BotAdminUser:
24 | """检查当前事件是否是消息事件且属于 Bot管理员"""
25 |
26 | __slots__ = ()
27 |
28 | def __repr__(self) -> str:
29 | return "BotAdminUser()"
30 |
31 | async def __call__(self, event: Event) -> bool:
32 | try:
33 | uid = (await User.get_user_by_event(event)).uid
34 | except Exception:
35 | return False
36 |
37 | admin_list: list[tuple[Any, ...]] = await get_admin_list()
38 | if uid in admin_list:
39 | return True
40 | else:
41 | return False
42 |
43 |
44 | class BotHelperUser:
45 | """检查当前事件是否是消息事件且属于 Bot协助者"""
46 |
47 | __slots__ = ()
48 |
49 | def __repr__(self) -> str:
50 | return "BotHelperUser()"
51 |
52 | async def __call__(self, event: Event) -> bool:
53 | try:
54 | uid = (await User.get_user_by_event(event)).uid
55 | except Exception:
56 | return False
57 |
58 | helper_list: list[tuple[Any, ...]] = await get_helper_list()
59 | if uid in helper_list:
60 | return True
61 | else:
62 | return False
63 |
64 |
65 | ADMIN: Permission = Permission(BotAdminUser())
66 | """Bot 管理员"""
67 | HELPER: Permission = Permission(BotHelperUser())
68 | """Bot 协助者"""
69 |
--------------------------------------------------------------------------------
/sora/plugins/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import require
2 |
3 | require("nonebot_plugin_alconna")
4 | from nonebot_plugin_alconna import add_global_extension
5 | from nonebot_plugin_alconna.builtins.extensions.telegram import TelegramSlashExtension
6 |
7 | add_global_extension(TelegramSlashExtension())
8 |
--------------------------------------------------------------------------------
/sora/plugins/admin/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import require
2 | from nonebot.rule import to_me
3 |
4 | require("nonebot_plugin_saa")
5 | require("nonebot_plugin_alconna")
6 |
7 | from nonebot_plugin_saa import Text
8 | from nonebot_plugin_alconna import (
9 | Args,
10 | Query,
11 | Option,
12 | Alconna,
13 | Subcommand,
14 | CommandMeta,
15 | namespace,
16 | on_alconna,
17 | )
18 |
19 | from sora.log import logger
20 | from sora.hook import on_startup
21 | from sora.utils.api import random_text
22 | from sora.utils.annotated import UserInfo
23 | from sora.permission import get_admin_list, get_helper_list
24 |
25 | from .utils import check_permission
26 |
27 | authorize: str = ""
28 |
29 | __usage__ = """
30 | USER
31 | 查看 Bot管理员 列表:/admin -l
32 |
33 | BOT_ADMIN
34 | 初始化权限:/admin -i
35 | 增加管理员:/admin add -a
36 | 增加协助者:/admin add -h
37 | 取消管理员:/admin remove -a
38 | 取消协助者:/admin remove -h
39 | """
40 |
41 | with namespace("Sora") as ns:
42 | ns.builtin_option_name["help"] = {"--h", "--help"}
43 | admin = on_alconna(
44 | Alconna(
45 | "admin",
46 | Option("-l|--list", help_text="查看 Bot管理员 列表"),
47 | Option("-i|--init", Args["token", str], help_text="初始化权限"),
48 | Subcommand(
49 | "add",
50 | Option("-a|--admin", Args["target?", int], help_text="增加 Bot管理员"),
51 | Option("-h|--helper", Args["target?", int], help_text="增加 Bot协助者"),
52 | ),
53 | Subcommand(
54 | "remove",
55 | Option("-a|--admin", Args["target?", int], help_text="取消 Bot管理员"),
56 | Option("-h|--helper", Args["target?", int], help_text="取消 Bot协助者"),
57 | ),
58 | meta=CommandMeta(
59 | description="Bot管理员相关指令",
60 | usage=__usage__,
61 | example="/admin -l",
62 | compact=True,
63 | ),
64 | ),
65 | priority=5,
66 | block=True,
67 | rule=to_me(),
68 | )
69 | admin.shortcut(
70 | r"(添加|增加)管理员\s+(\d+)",
71 | command="admin add --admin",
72 | arguments=["{1}"],
73 | fuzzy=True,
74 | prefix=True,
75 | )
76 | admin.shortcut(
77 | r"(添加|增加)协助者\s+(\d+)",
78 | command="admin add --helper",
79 | arguments=["{1}"],
80 | fuzzy=True,
81 | prefix=True,
82 | )
83 | admin.shortcut(
84 | r"(移除|取消)管理员\s+(\d+)",
85 | command="admin remove --admin",
86 | arguments=["{1}"],
87 | fuzzy=True,
88 | prefix=True,
89 | )
90 | admin.shortcut(
91 | r"(移除|取消)协助者\s+(\d+)",
92 | command="admin remove --helper",
93 | arguments=["{1}"],
94 | fuzzy=True,
95 | prefix=True,
96 | )
97 |
98 |
99 | @admin.assign("list")
100 | async def list():
101 | admin_list = await get_admin_list()
102 | helper_list = await get_helper_list()
103 |
104 | await Text(f"Bot管理员列表:\n{admin_list}\nBot协助者列表:\n{helper_list}").send(
105 | at_sender=True
106 | )
107 | await admin.finish()
108 |
109 |
110 | @admin.assign("init")
111 | async def init(user: UserInfo, token: str):
112 | global authorize
113 | if token == authorize:
114 | await user.filter(uid=user.uid).update(permission="ADMIN")
115 | await Text("已成功授权 Bot管理员 权限").send(at_sender=True)
116 | else:
117 | await Text("token不对呢,返回控制台检查一下吧").send(at_sender=True)
118 | await admin.finish()
119 |
120 |
121 | @admin.assign("add")
122 | async def add(
123 | user: UserInfo,
124 | admin_target: Query[int] = Query("add.admin.target"),
125 | helper_target: Query[int] = Query("add.helper.target"),
126 | ):
127 | if not await check_permission(user.uid, "ADMIN"):
128 | await Text("权限不足,需 Bot管理员 权限").send(at_sender=True)
129 | await admin.finish()
130 |
131 | if admin_target.available:
132 | if await user.filter(bind__uid=str(admin_target.result)).exists():
133 | await user.filter(uid=admin_target.result).update(permission="ADMIN")
134 | await Text(f"已成功增加 Bot管理员:{admin_target.result}").send(at_sender=True)
135 |
136 | elif admin_target.result == 0:
137 | ...
138 |
139 | else:
140 | await Text(f"用户 {admin_target.result} 不存在").send(at_sender=True)
141 |
142 | if helper_target.available:
143 | if await user.filter(bind__uid=str(admin_target.result)).exists():
144 | await user.filter(uid=helper_target.result).update(permission="HELPER")
145 | await Text(f"已成功增加 Bot协助者:{helper_target.result}").send(at_sender=True)
146 |
147 | elif helper_target.result == 0:
148 | ...
149 |
150 | else:
151 | await Text(f"用户 {helper_target.result} 不存在").send(at_sender=True)
152 |
153 | await admin.finish()
154 |
155 |
156 | @admin.assign("remove")
157 | async def remove(
158 | user: UserInfo,
159 | admin_target: Query[int] = Query("remove.admin.target"),
160 | helper_target: Query[int] = Query("remove.helper.target"),
161 | ):
162 | if not await check_permission(user.uid, "ADMIN"):
163 | await Text("权限不足,需 Bot管理员 权限").send(at_sender=True)
164 | await admin.finish()
165 |
166 | if admin_target.available:
167 | if await user.filter(bind__uid=str(admin_target.result)).exists():
168 | await user.filter(uid=admin_target.result).update(permission="NORMAL")
169 | await Text(f"已取消 {admin_target.result} 的 Bot管理员 身份").send(at_sender=True)
170 |
171 | elif admin_target.result == 0:
172 | ...
173 |
174 | else:
175 | await Text(f"用户 {admin_target.result} 不存在").send(at_sender=True)
176 |
177 | if helper_target.available:
178 | if await user.filter(bind__uid=str(admin_target.result)).exists():
179 | await user.filter(uid=helper_target.result).update(permission="NORMAL")
180 | await Text(f"已取消 {helper_target.result} 的 Bot协助者 身份").send(at_sender=True)
181 |
182 | elif helper_target.result == 0:
183 | ...
184 |
185 | else:
186 | await Text(f"用户 {helper_target.result} 不存在").send(at_sender=True)
187 |
188 | await admin.finish()
189 |
190 |
191 | @on_startup
192 | async def atuhorize_admin():
193 | if not await get_admin_list():
194 | global authorize
195 | authorize = random_text(20)
196 | logger.info(f"已自动生成授权token:{authorize}")
197 | logger.info(f"在任意平台发送 /admin -i {authorize} 即可获取 Bot管理员 权限")
198 |
--------------------------------------------------------------------------------
/sora/plugins/admin/utils.py:
--------------------------------------------------------------------------------
1 | from typing import Literal
2 |
3 | from sora.database import User
4 |
5 |
6 | async def check_permission(
7 | uid: str, permission: Literal["ADMIN", "HELPER", "NORMAL", "BANNED"]
8 | ) -> bool:
9 | user = await User.get_user_by_uid(uid)
10 | return user.permission == permission
11 |
--------------------------------------------------------------------------------
/sora/plugins/ban/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import require
2 | from nonebot.rule import to_me
3 | from nonebot.internal.adapter import Bot
4 |
5 | require("nonebot_plugin_saa")
6 | require("nonebot_plugin_alconna")
7 | from nonebot_plugin_saa import Text
8 | from arclet.alconna.args import Args
9 | from arclet.alconna.base import Option
10 | from arclet.alconna.core import Alconna
11 | from arclet.alconna.typing import CommandMeta
12 | from nonebot_plugin_alconna import Match, on_alconna
13 | from nonebot_plugin_alconna.uniseg.segment import At
14 |
15 | from sora.log import logger
16 | from sora.database import Ban, User
17 | from sora.permission import ADMIN, HELPER
18 |
19 | ban = on_alconna(
20 | Alconna(
21 | "ban",
22 | Option(
23 | "add",
24 | Args["target", At | int]["hours?", int]["minutes?", int],
25 | help_text="封禁用户",
26 | ),
27 | Option("remove", Args["target", At | int], help_text="解封用户"),
28 | Option("-l|--list", help_text="查询所有被封禁用户"),
29 | meta=CommandMeta(
30 | description="你被逮捕了!丢进小黑屋!",
31 | usage="@bot /ban [小时] [分钟]",
32 | example="""
33 | @bot /ban add @user
34 | @bot /ban add 2023081136 2
35 | @bot /ban add @user 2 30
36 | @bot /ban remove @user
37 | @bot /ban -l
38 | """,
39 | compact=True,
40 | ),
41 | ),
42 | priority=5,
43 | block=True,
44 | rule=to_me(),
45 | permission=ADMIN | HELPER,
46 | )
47 |
48 |
49 | @ban.assign("add")
50 | async def add(
51 | bot: Bot,
52 | target: At | int,
53 | hours: Match[int],
54 | minutes: Match[int],
55 | ):
56 | if isinstance(target, At):
57 | pid = target.target
58 | if pid == bot.self_id:
59 | await Text("不要禁我,会把我弄哭的哦").finish(at_sender=True)
60 | if not await User.check_exists(bot.adapter.get_name(), pid):
61 | await Text("您@的用户还未注册喔").finish(at_sender=True)
62 | uid = (await User.get_user_by_pid(pid)).uid
63 | else:
64 | uid = str(target)
65 |
66 | user_name = (await User.get_user_by_uid(uid)).user_name
67 |
68 | if hours.available:
69 | if minutes.available:
70 | minutes_result = minutes.result
71 | else:
72 | minutes_result = 0
73 | hours_result = hours.result
74 | logger.info(f"封禁目标:{uid}({user_name}),时长:{hours_result}小时{minutes_result}分钟")
75 | await Ban.ban(uid, duration=convert_to_seconds(hours_result, minutes_result))
76 | await Text(
77 | f"已成功封禁用户:{uid}({user_name}),时长:{hours_result}小时{minutes_result}分钟"
78 | ).send(at_sender=True)
79 | else:
80 | logger.info(f"永久封禁目标:{uid}({user_name})")
81 | await Ban.ban(uid, duration=-1)
82 | await Text(f"已永久封禁用户:{uid}({user_name})").send(at_sender=True)
83 |
84 | await ban.finish()
85 |
86 |
87 | @ban.assign("remove")
88 | async def unban(
89 | bot: Bot,
90 | target: At | int,
91 | hours: Match[int],
92 | minutes: Match[int],
93 | ):
94 | if isinstance(target, At):
95 | pid = target.target
96 | if not await User.check_exists(bot.adapter.get_name(), pid):
97 | await Text("您@的用户还未注册喔").finish(at_sender=True)
98 | uid = (await User.get_user_by_pid(pid)).uid
99 | else:
100 | uid = str(target)
101 |
102 | user_name = (await User.get_user_by_uid(uid)).user_name
103 |
104 | if hours.available:
105 | if minutes.available:
106 | minutes_result = minutes.result
107 | else:
108 | minutes_result = 0
109 | hours_result = hours.result
110 | logger.info(f"封禁目标:{uid}({user_name}),时长:{hours_result}小时{minutes_result}分钟")
111 | await Ban.unban(uid)
112 | await Text(
113 | f"已成功封禁用户:{uid}({user_name}),时长:{hours_result}小时{minutes_result}分钟"
114 | ).send(at_sender=True)
115 | else:
116 | logger.info(f"永久封禁目标:{uid}({user_name})")
117 | await Ban.unban(uid)
118 | await Text(f"已永久封禁用户:{uid}({user_name})").send(at_sender=True)
119 |
120 | await ban.finish()
121 |
122 |
123 | @ban.assign("list")
124 | async def list():
125 | ban_users = await Ban.all()
126 | if ban_users:
127 | message = "\n".join(
128 | [
129 | f"ID: {ban_user.id}\n"
130 | f"用户ID: {ban_user.uid}\n"
131 | f"封禁开始时间: {ban_user.ban_time}\n"
132 | f"封禁时长: {ban_user.duration}\n"
133 | for ban_user in ban_users
134 | ]
135 | )
136 | else:
137 | message = "当前还没有被封禁用户呢"
138 | await Text(message).finish(at_sender=True)
139 |
140 |
141 | def convert_to_seconds(hours: int, minutes: int):
142 | total_minutes = hours * 60 + minutes
143 | total_seconds = total_minutes * 60
144 | return total_seconds
145 |
--------------------------------------------------------------------------------
/sora/plugins/control/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 | import asyncio
3 | import platform
4 |
5 | from nonebot import require
6 | from nonebot.rule import to_me
7 |
8 | require("nonebot_plugin_saa")
9 | require("nonebot_plugin_alconna")
10 | from nonebot_plugin_saa import Text
11 | from arclet.alconna.args import Args
12 | from arclet.alconna.core import Alconna
13 | from arclet.alconna.typing import CommandMeta
14 | from nonebot_plugin_alconna.uniseg import UniMessage
15 | from nonebot_plugin_alconna import Match, MultiVar, AlconnaMatcher, on_alconna
16 |
17 | from sora.config import bot_config
18 | from sora.version import __version__
19 | from sora.permission import ADMIN, HELPER
20 | from sora.utils.helpers import HandleCancellation
21 | from sora.utils.update import CheckUpdate, update
22 |
23 | nickname = list(bot_config.nickname)
24 |
25 | update_cmd = on_alconna(
26 | Alconna(
27 | "更新",
28 | meta=CommandMeta(
29 | description="更新林汐",
30 | usage="@bot /更新",
31 | example="@bot /更新",
32 | compact=True,
33 | ),
34 | ),
35 | permission=ADMIN | HELPER,
36 | rule=to_me(),
37 | priority=1,
38 | block=True,
39 | )
40 | check_update_cmd = on_alconna(
41 | Alconna(
42 | "检查更新",
43 | meta=CommandMeta(
44 | description="检查更新",
45 | usage="@bot /检查林汐版本",
46 | example="@bot /检查更新",
47 | compact=True,
48 | ),
49 | ),
50 | permission=ADMIN | HELPER,
51 | rule=to_me(),
52 | priority=1,
53 | block=True,
54 | )
55 |
56 | reboot = on_alconna(
57 | Alconna(
58 | "reboot",
59 | meta=CommandMeta(
60 | description="重启林汐",
61 | usage="@bot /reboot",
62 | example="@bot /reboot",
63 | compact=True,
64 | ),
65 | ),
66 | permission=ADMIN | HELPER,
67 | rule=to_me(),
68 | priority=1,
69 | block=True,
70 | )
71 | run_cmd = on_alconna(
72 | Alconna(
73 | "cmd",
74 | Args["cmd?", MultiVar(str, "*")],
75 | meta=CommandMeta(
76 | description="运行终端命令",
77 | usage="@bot /cmd",
78 | example="@bot /cmd",
79 | compact=True,
80 | ),
81 | ),
82 | permission=ADMIN,
83 | rule=to_me(),
84 | priority=1,
85 | block=True,
86 | )
87 |
88 |
89 | @update_cmd.handle()
90 | async def _():
91 | await update_cmd.send(f"{nickname[0]}开始更新", at_sender=True)
92 | result = await update()
93 | await Text(result).send(at_sender=True)
94 | await update_cmd.finish()
95 |
96 |
97 | @check_update_cmd.handle()
98 | async def _():
99 | latest_version, update_time = await CheckUpdate.show_latest_version()
100 | if latest_version and update_time:
101 | if latest_version != __version__:
102 | await Text(f"新版本已发布, 请更新\n最新版本: {latest_version} 更新时间: {update_time}").send(
103 | at_sender=True
104 | )
105 | else:
106 | await Text("当前已是最新版本").send(at_sender=True)
107 | await check_update_cmd.finish()
108 |
109 |
110 | @reboot.handle()
111 | async def _():
112 | await Text(f"开始重启{nickname[0]}..请稍等...").send(at_sender=True)
113 | if str(platform.system()).lower() == "windows":
114 | import sys
115 |
116 | python = sys.executable
117 | os.execl(python, python, *sys.argv)
118 | else:
119 | os.system("./restart.sh")
120 |
121 |
122 | @run_cmd.handle()
123 | async def _(matcher: AlconnaMatcher, cmd: Match[tuple[str, ...]]):
124 | if cmd.available:
125 | cmd_result = " ".join(cmd.result)
126 | matcher.set_path_arg("cmd", cmd_result)
127 |
128 |
129 | @run_cmd.got_path(
130 | "cmd",
131 | prompt=UniMessage.template("{:At(user, $event.get_user_id())} 请输入你要运行的命令"),
132 | parameterless=[HandleCancellation("已取消")],
133 | )
134 | async def _(cmd: str):
135 | await Text(f"开始执行 {cmd}").send(at_sender=True)
136 | p = await asyncio.subprocess.create_subprocess_shell(
137 | cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
138 | )
139 | stdout, stderr = await p.communicate()
140 | try:
141 | result = (stdout or stderr).decode("utf-8")
142 | except Exception:
143 | result = str(stdout or stderr)
144 | await Text(f"{cmd}\n运行结果:\n{result}").send(at_sender=True)
145 | await run_cmd.finish()
146 |
--------------------------------------------------------------------------------
/sora/plugins/control/data_source.py:
--------------------------------------------------------------------------------
1 | import os
2 | import platform
3 | from pathlib import Path
4 |
5 | from sora.log import logger
6 | from sora.config import bot_config
7 | from sora.hook import on_bot_connect
8 |
9 |
10 | @on_bot_connect
11 | async def remind():
12 | if str(platform.system()).lower() != "windows":
13 | restart = Path() / "restart.sh"
14 | if not restart.exists():
15 | with open(restart, "w", encoding="utf8") as f:
16 | f.write(
17 | "pid=$(netstat -tunlp | grep "
18 | + str(bot_config.port)
19 | + " | awk '{print $7}')\n"
20 | "pid=${pid%/*}\n"
21 | "kill -9 $pid\n"
22 | "sleep 3\n"
23 | "python3 bot.py"
24 | )
25 | os.system("chmod +x ./restart.sh")
26 | logger.info("配置", "已自动生成 restart.sh 重启脚本,请检查脚本是否与本地指令符合..")
27 |
--------------------------------------------------------------------------------
/sora/plugins/echo.py:
--------------------------------------------------------------------------------
1 | from nonebot import require
2 |
3 | require("nonebot_plugin_alconna")
4 | from nonebot_plugin_alconna import funcommand
5 |
6 | from sora.permission import ADMIN, HELPER
7 |
8 |
9 | @funcommand(permission=ADMIN | HELPER)
10 | async def echo(msg: str):
11 | return msg
12 |
--------------------------------------------------------------------------------
/sora/plugins/help/__init__.py:
--------------------------------------------------------------------------------
1 | from typing import Literal
2 | from collections import deque
3 |
4 | from nonebot import require
5 | from nonebot.rule import to_me
6 |
7 | require("nonebot_plugin_saa")
8 | require("nonebot_plugin_alconna")
9 | from nonebot_plugin_saa import Text
10 | from arclet.alconna.base import Option
11 | from arclet.alconna.core import Alconna
12 | from arclet.alconna.args import Arg, Args
13 | from arclet.alconna.arparma import Arparma
14 | from arclet.alconna.typing import CommandMeta
15 | from arclet.alconna.manager import command_manager
16 | from nonebot_plugin_alconna import Match, on_alconna, store_true
17 | from nonebot_plugin_alconna.extension import Extension, add_global_extension
18 |
19 | from .config import Config
20 |
21 | config = Config.load_config()
22 |
23 | _help = Alconna(
24 | "help",
25 | Args["plugin?", str],
26 | Option("--hide", action=store_true, default=False, help_text="是否列出隐藏命令"),
27 | meta=CommandMeta(
28 | description="获取林汐帮助文档",
29 | usage="@bot /help",
30 | example="@bot /help",
31 | compact=True,
32 | ),
33 | )
34 |
35 | _statis = Alconna(
36 | "statis",
37 | Arg("type", Literal["show", "most", "least"], "most"),
38 | Args["count", int, config.message_count],
39 | meta=CommandMeta(
40 | description="命令统计",
41 | usage="@bot /statis most",
42 | example="@bot /statis most",
43 | compact=True,
44 | ),
45 | )
46 |
47 | record = deque(maxlen=256)
48 |
49 |
50 | class HelperExtension(Extension):
51 | @property
52 | def priority(self) -> int:
53 | return 5
54 |
55 | @property
56 | def id(self) -> str:
57 | return "SoraBot:HelperExtension"
58 |
59 | async def parse_wrapper(self, bot, state, event, res: Arparma) -> None:
60 | if res.source != _statis.path:
61 | record.append((res.source, res.origin))
62 |
63 |
64 | add_global_extension(HelperExtension())
65 |
66 | help_cmd = on_alconna(_help, auto_send_output=True, rule=to_me())
67 | statis_cmd = on_alconna(_statis, auto_send_output=True, rule=to_me())
68 | statis_cmd.shortcut("消息统计", {"args": ["show"], "prefix": True})
69 | statis_cmd.shortcut("命令统计", {"args": ["most"], "prefix": True})
70 |
71 |
72 | @help_cmd.handle()
73 | async def _(plugin: Match[str]):
74 | if plugin.available:
75 | plugin_help = command_manager.command_help(plugin.result)
76 | if plugin_help is None:
77 | await Text("唔...没有找到帮助呢").finish(at_sender=True)
78 | else:
79 | await Text(plugin_help).finish(at_sender=True)
80 |
81 | await Text(
82 | command_manager.all_command_help(show_index=True, namespace="Alconna")
83 | ).send(at_sender=True)
84 | await help_cmd.finish()
85 |
86 |
87 | @statis_cmd.assign("type", "show")
88 | async def statis_cmd_show(count: int):
89 | if not record:
90 | await statis_cmd.finish("暂无命令记录")
91 |
92 | await statis_cmd.finish(
93 | "最近的命令记录为:\n"
94 | + "\n".join([f"[{i}]: {record[i][1]}" for i in range(min(count, len(record)))])
95 | )
96 |
97 |
98 | @statis_cmd.assign("type", "most")
99 | async def statis_cmd_most(arp: Arparma):
100 | if not record:
101 | await statis_cmd.finish("暂无命令记录")
102 |
103 | length = len(record)
104 | table = {}
105 | for i, r in enumerate(record):
106 | source = r[0]
107 | if source not in table:
108 | table[source] = 0
109 | table[source] += i / length
110 | sort = sorted(table.items(), key=lambda x: x[1], reverse=True)
111 | sort = sort[: arp.query[int]("count")]
112 | await statis_cmd.finish(
113 | "以下按照命令使用频率排序\n" + "\n".join(f"[{k}] {v[0]}" for k, v in enumerate(sort))
114 | )
115 |
116 |
117 | @statis_cmd.assign("type", "least")
118 | async def statis_cmd_least(arp: Arparma):
119 | if not record:
120 | await statis_cmd.finish("暂无命令记录")
121 | length = len(record)
122 | table = {}
123 | for i, r in enumerate(record):
124 | source = r[0]
125 | if r[source] not in table:
126 | table[source] = 0
127 | table[source] += i / length
128 | sort = sorted(table.items(), key=lambda x: x[1], reverse=False)
129 | sort = sort[: arp.query[int]("count")]
130 | await statis_cmd.finish(
131 | "以下按照命令使用频率排序\n" + "\n".join(f"[{k}] {v[0]}" for k, v in enumerate(sort))
132 | )
133 |
--------------------------------------------------------------------------------
/sora/plugins/help/config.py:
--------------------------------------------------------------------------------
1 | from pydantic import Field
2 |
3 | from sora.config import BaseConfig
4 |
5 |
6 | class Config(BaseConfig):
7 | index: bool = False
8 | """是否展示索引"""
9 |
10 | max_length: int = -1
11 | """单个页面展示的最大长度"""
12 |
13 | message_count: int = Field(10, ge=1, le=120)
14 | """统计命令显示的消息数量"""
15 |
--------------------------------------------------------------------------------
/sora/plugins/hooks/__init__.py:
--------------------------------------------------------------------------------
1 | from . import check_ban as check_ban
2 | from . import user_exist as user_exist
3 | from . import level_upgrade as level_upgrade
4 |
--------------------------------------------------------------------------------
/sora/plugins/hooks/check_ban.py:
--------------------------------------------------------------------------------
1 | from nonebot.adapters import Bot, Event
2 | from nonebot.message import run_preprocessor
3 | from nonebot.exception import IgnoredException
4 |
5 | from sora.log import logger
6 | from sora.database import Ban, User
7 |
8 |
9 | @run_preprocessor
10 | async def _(bot: Bot, event: Event):
11 | platform = bot.adapter.get_name()
12 | pid = event.get_user_id()
13 | if await User.check_exists(platform, pid):
14 | uid = (await User.get_user_by_pid(pid)).uid
15 | is_ban = await Ban.is_banned(uid)
16 | if is_ban:
17 | logger.debug(f"用户 {uid} 处于黑名单中")
18 | raise IgnoredException("用户处于黑名单中")
19 |
--------------------------------------------------------------------------------
/sora/plugins/hooks/level_upgrade.py:
--------------------------------------------------------------------------------
1 | from nonebot import require
2 | from nonebot.params import Depends
3 | from tortoise.expressions import F
4 | from nonebot.message import run_postprocessor
5 | from nonebot.internal.adapter import Bot, Event
6 |
7 | require("nonebot_plugin_saa")
8 | from nonebot_plugin_saa import MessageFactory, PlatformTarget, extract_target
9 |
10 | from sora.database import User
11 | from sora.utils.annotated import UserInfo
12 | from sora.plugins.sign.config import GameConfig
13 | from sora.plugins.sign.utils import calc_exp_threshold
14 |
15 | level_config = (GameConfig.load_config("game")).level
16 |
17 |
18 | @run_postprocessor
19 | async def level_up(
20 | bot: Bot,
21 | event: Event,
22 | userInfo: UserInfo,
23 | target: PlatformTarget = Depends(extract_target),
24 | ):
25 | if not await User.check_exists(bot.type, event.get_user_id()):
26 | return
27 |
28 | uid = userInfo.uid
29 | user_level = userInfo.level
30 | user_exp = userInfo.exp
31 | exp_threshold = calc_exp_threshold(user_level)
32 |
33 | if user_exp >= exp_threshold:
34 | await User.filter(uid=uid).update(
35 | level=F("level") + 1, exp=F("exp") - exp_threshold
36 | )
37 | await MessageFactory(
38 | f"🎉 经验值溢出,等级 + 1!\n当前等级:{user_level + 1},经验值:{user_exp - exp_threshold}"
39 | ).send_to(target, bot)
40 |
--------------------------------------------------------------------------------
/sora/plugins/hooks/user_exist.py:
--------------------------------------------------------------------------------
1 | """
2 | 该钩子为事件预处理钩子
3 | 用来在处理事件前先判断用户是否存在
4 | 如果不存在则自动为其注册账号
5 | """
6 | import inspect
7 |
8 | from nonebot import require
9 | from nonebot.params import Depends
10 | from nonebot.internal.adapter import Bot
11 | from nonebot.message import run_preprocessor
12 | from nonebot.adapters.qq.exception import AuditException
13 |
14 | require("nonebot_plugin_saa")
15 | require("nonebot_plugin_userinfo")
16 | from nonebot_plugin_userinfo import UserInfo, EventUserInfo
17 | from nonebot_plugin_saa import MessageFactory, PlatformTarget, extract_target
18 |
19 | from sora.log import logger
20 | from sora.database import Bind, Sign, User
21 | from sora.utils.api import generate_id, random_text
22 |
23 |
24 | @run_preprocessor
25 | async def _(
26 | bot: Bot,
27 | user: UserInfo = EventUserInfo(),
28 | target: PlatformTarget = Depends(extract_target),
29 | ):
30 | uid = generate_id()
31 | pid = user.user_id
32 | user_name = user.user_name
33 | platform = bot.adapter.get_name()
34 | exist = await User.check_exists(platform, pid)
35 |
36 | if not exist:
37 | if await User.check_username(user_name):
38 | user_name += random_text(2)
39 |
40 | userinfo: User = await User.create_user(uid, user_name)
41 | await Sign.create(uid=uid)
42 | await Bind.create(uid=uid, platform=platform, pid=pid, info=userinfo)
43 |
44 | logger.success(f"用户 {user_name}({uid}) 账号信息初始化成功!")
45 |
46 | try:
47 | await MessageFactory(
48 | inspect.cleandoc(
49 | f"""
50 | 第一次使用林汐?
51 | 我们已为您注册全新的账户!
52 | - 用户名:{user_name}(lv.1)
53 | - ID:{uid}
54 | 顺便奖励您 50 枚硬币。
55 | 『提示』如果您在其它平台已有账户,可发送 /bind 指令绑定!
56 | """
57 | )
58 | ).send_to(target, bot)
59 | except AuditException:
60 | pass
61 |
--------------------------------------------------------------------------------
/sora/plugins/network/__init__.py:
--------------------------------------------------------------------------------
1 | """网络工具"""
2 |
3 | from dns import resolver
4 | from nonebot import require
5 | from nonebot.rule import to_me
6 |
7 | require("nonebot_plugin_saa")
8 | require("nonebot_plugin_alconna")
9 | from nonebot_plugin_saa import Text
10 | from arclet.alconna.args import Args
11 | from arclet.alconna.core import Alconna
12 | from arclet.alconna.typing import CommandMeta
13 | from nonebot_plugin_alconna import on_alconna
14 |
15 | dns = on_alconna(
16 | Alconna(
17 | "dns",
18 | Args["domain#域名", "url"]["rdtype?#记录名", "str", "A"],
19 | meta=CommandMeta(
20 | description="DNS 查询",
21 | usage="@bot /dns <域名> [记录名]",
22 | example="@bot /dns google.com",
23 | compact=True,
24 | ),
25 | ),
26 | rule=to_me(),
27 | priority=99,
28 | block=True,
29 | )
30 |
31 |
32 | async def _(domain: str, rdtype: str):
33 | try:
34 | answers = resolver.resolve(domain, rdtype)
35 | result = answers.rrset
36 | if result is None:
37 | return
38 | for answer in result:
39 | await Text(answer.to_text()).finish(at_sender=True)
40 | except resolver.NXDOMAIN:
41 | await Text("No DNS record found for the domain.").finish(at_sender=True)
42 | except resolver.NoAnswer:
43 | await Text("No A record found for the domain.").finish(at_sender=True)
44 |
--------------------------------------------------------------------------------
/sora/plugins/pic/__init__.py:
--------------------------------------------------------------------------------
1 | from . import pic_jk as pic_jk
2 | from . import pic_cos as pic_cos
3 | from . import pic_legs as pic_legs
4 | from . import pic_setu as pic_setu
5 |
--------------------------------------------------------------------------------
/sora/plugins/pic/pic_cos.py:
--------------------------------------------------------------------------------
1 | from nonebot import require
2 |
3 | require("nonebot_plugin_saa")
4 | require("nonebot_plugin_alconna")
5 |
6 | from nonebot_plugin_saa import Text, Image
7 | from nonebot_plugin_alconna import Alconna, CommandMeta, on_alconna
8 |
9 | from sora.log import logger
10 |
11 | URL = "https://picture.yinux.workers.dev"
12 |
13 | cos = on_alconna(
14 | Alconna(
15 | "cos",
16 | meta=CommandMeta(
17 | description="三次元也不戳,嘿嘿嘿",
18 | usage="/cos",
19 | example="/cos",
20 | compact=True,
21 | ),
22 | ),
23 | priority=50,
24 | block=True,
25 | )
26 | cos.shortcut("coser", prefix=True)
27 |
28 |
29 | @cos.handle()
30 | async def _():
31 | try:
32 | await Image(URL).send(at_sender=True)
33 | except Exception as e:
34 | logger.error(f"{e}")
35 | await Text("你cos给我看!").send(at_sender=True)
36 | await cos.finish()
37 |
--------------------------------------------------------------------------------
/sora/plugins/pic/pic_jk.py:
--------------------------------------------------------------------------------
1 | from nonebot import require
2 |
3 | from sora.utils.requests import AsyncHttpx
4 |
5 | require("nonebot_plugin_saa")
6 | require("nonebot_plugin_alconna")
7 |
8 | from nonebot_plugin_saa import Image
9 | from arclet.alconna.core import Alconna
10 | from arclet.alconna.typing import CommandMeta
11 | from nonebot_plugin_alconna import on_alconna
12 |
13 | URL = "https://api.sevin.cn/api/jk.php"
14 |
15 | jk = on_alconna(
16 | Alconna(
17 | "jk",
18 | meta=CommandMeta(
19 | description="谁不喜欢看Jk呢?",
20 | usage="/jk",
21 | example="/jk",
22 | compact=True,
23 | ),
24 | ),
25 | priority=50,
26 | block=True,
27 | )
28 | jk.shortcut("看裙", prefix=True)
29 |
30 |
31 | @jk.handle()
32 | async def _():
33 | res = await AsyncHttpx.get(URL)
34 | data = res.text.strip()
35 | await Image(data).finish(at_sender=True)
36 |
--------------------------------------------------------------------------------
/sora/plugins/pic/pic_legs.py:
--------------------------------------------------------------------------------
1 | from nonebot import require
2 |
3 | from sora.utils.requests import AsyncHttpx
4 |
5 | require("nonebot_plugin_saa")
6 | require("nonebot_plugin_alconna")
7 |
8 | from nonebot_plugin_saa import Image
9 | from arclet.alconna.core import Alconna
10 | from arclet.alconna.typing import CommandMeta
11 | from nonebot_plugin_alconna import on_alconna
12 |
13 | URL = "http://81.70.100.130/api/tu.php"
14 |
15 | legs = on_alconna(
16 | Alconna(
17 | "玉足",
18 | meta=CommandMeta(
19 | description="什么都玉足只会害了你",
20 | usage="/玉足",
21 | example="/玉足",
22 | compact=True,
23 | ),
24 | ),
25 | priority=50,
26 | block=True,
27 | )
28 | legs.shortcut("看腿", prefix=True)
29 |
30 |
31 | @legs.handle()
32 | async def _():
33 | res = await AsyncHttpx.get(URL)
34 | data = res.text.strip()
35 | await Image(data).finish(at_sender=True)
36 |
--------------------------------------------------------------------------------
/sora/plugins/pic/pic_setu.py:
--------------------------------------------------------------------------------
1 | from nonebot import require
2 | from httpx import ConnectError
3 |
4 | require("nonebot_plugin_saa")
5 | require("nonebot_plugin_alconna")
6 |
7 | from arclet.alconna.args import Args
8 | from arclet.alconna.base import Option
9 | from arclet.alconna.core import Alconna
10 | from arclet.alconna.typing import MultiVar, CommandMeta
11 | from nonebot_plugin_alconna import Match, Query, on_alconna, store_true
12 | from nonebot_plugin_saa import Text, Image, MessageFactory, AggregatedMessageFactory
13 |
14 | from sora.log import logger
15 | from sora.utils.requests import AsyncHttpx
16 |
17 | URL: str = "https://api.lolicon.app/setu/v2?r18={r18}&num={num}&tag={tag}"
18 |
19 | setu = on_alconna(
20 | Alconna(
21 | "setu",
22 | Args["count", int, 1],
23 | Option("-r|--r18", action=store_true, default=False, help_text="是否开启 R18 模式"),
24 | Option("-t|--tags", Args["tags", MultiVar(str, "*")], help_text="指定标签"),
25 | meta=CommandMeta(
26 | description="谁不喜欢看色图呢?",
27 | usage="/setu",
28 | example="/色图",
29 | compact=True,
30 | ),
31 | ),
32 | priority=50,
33 | block=True,
34 | )
35 |
36 |
37 | def wrapper(slot: int | str, content: str | None) -> str | None:
38 | if slot == 0:
39 | if not content:
40 | return "1"
41 | if content == "点":
42 | import random
43 |
44 | return str(random.randint(1, 5))
45 | return content
46 |
47 |
48 | setu.shortcut(
49 | r"(?:要|我要|给我|来|抽)(点|\d*)(?:张|个|份|幅)?(?:色|涩|瑟)图",
50 | command="setu {0} -t",
51 | fuzzy=True,
52 | wrapper=wrapper,
53 | )
54 |
55 | setu.shortcut(
56 | r"(?:要|我要|给我|来|抽)(点|\d*)(?:张|个|份|幅)?(.+?)的?(?:色|涩|瑟)图",
57 | command="setu {0}",
58 | arguments=["tags", "{1}"],
59 | fuzzy=True,
60 | wrapper=wrapper,
61 | )
62 |
63 |
64 | @setu.handle()
65 | async def setu_(
66 | count: int, tags: Match[tuple[str, ...]], r18: Query[bool] = Query("r18.value")
67 | ):
68 | tags_result = "|".join(tags.result) if tags.available else ""
69 | r18_result = 1 if r18.result else 0
70 |
71 | url = URL.format(r18=str(r18_result), num=str(count), tag=tags_result)
72 |
73 | try:
74 | messages = []
75 |
76 | res = await AsyncHttpx.get(url)
77 | parsed_data = res.json()
78 |
79 | if count == 1:
80 | title = parsed_data["data"][0]["title"]
81 | original = parsed_data["data"][0]["urls"]["original"]
82 | await MessageFactory([Text(title), Image(original)]).finish(at_sender=True)
83 |
84 | for item in parsed_data["data"]:
85 | title = item["title"]
86 | original = item["urls"]["original"]
87 |
88 | message = MessageFactory([Text(title), Image(original)])
89 | messages.append(message)
90 |
91 | await AggregatedMessageFactory(messages).send()
92 |
93 | except ConnectError:
94 | logger.error("网络错误")
95 |
--------------------------------------------------------------------------------
/sora/plugins/sign/__init__.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | from datetime import date, datetime
3 |
4 | from nonebot import require
5 | from nonebot.rule import to_me
6 | from tortoise.expressions import F
7 | from nonebot.internal.adapter import Bot
8 |
9 | require("nonebot_plugin_saa")
10 | require("nonebot_plugin_alconna")
11 |
12 | from nonebot_plugin_saa import Text
13 | from arclet.alconna.args import Args
14 | from arclet.alconna.base import Option
15 | from arclet.alconna.core import Alconna
16 | from arclet.alconna.typing import CommandMeta
17 | from nonebot_plugin_alconna import Match, on_alconna
18 | from nonebot_plugin_alconna.uniseg.segment import At
19 |
20 | from sora.utils.annotated import UserInfo
21 | from sora.database.models import Sign, User
22 |
23 | from .config import GameConfig
24 |
25 | sign_config = (GameConfig.load_config("game")).sign
26 |
27 | sign = on_alconna(
28 | Alconna(
29 | "sign",
30 | Option("info", Args["target?", At | int], alias={"信息"}, help_text="签到信息"),
31 | meta=CommandMeta(
32 | description="每日打卡",
33 | usage="签到:@bot /签到\n签到信息:@bot /签到信息 [At]\n当日签到排行:@bot /签到排行",
34 | example="@bot /签到",
35 | compact=True,
36 | ),
37 | ),
38 | priority=20,
39 | rule=to_me(),
40 | )
41 |
42 | sign.shortcut("签到", prefix=True)
43 |
44 |
45 | @sign.assign("$main")
46 | async def _(user: UserInfo):
47 | uid = user.uid
48 | signInfo = await Sign.get_sign_info(uid)
49 |
50 | if signInfo is None:
51 | last_sign = None
52 | total_days = continuous_days = 0
53 | else:
54 | last_sign = signInfo.last_sign
55 | total_days = signInfo.total_days
56 | continuous_days = signInfo.continuous_days
57 |
58 | if last_sign is not None and last_sign.date() == date.today():
59 | await Text("您今日已经签到过了,不可重复签到").finish(at_sender=True)
60 |
61 | if last_sign is not None and abs((date.today() - last_sign.date()).days) == 1:
62 | continuous_days += 1
63 | extra_coin = int(continuous_days * 1.5)
64 | msg = f"你当前连续签到 {str(continuous_days)} 天,额外奖励 {extra_coin} 枚硬币。"
65 | await User.reward(uid, reward={"coin": extra_coin})
66 | await Sign.filter(uid=uid).update(continuous_days=F("continuous_days") + 1)
67 | else:
68 | msg = "你当前连续签到 0 天,"
69 | extra_coin = 0
70 | await Sign.filter(uid=uid).update(continuous_days=0)
71 |
72 | amount = await User.reward(uid, reward=sign_config.rewards)
73 | await Sign.filter(uid=uid).update(
74 | total_days=total_days + 1, last_sign=datetime.now()
75 | )
76 |
77 | sign_coin = amount["coin"] + extra_coin
78 | sign_exp = amount["exp"]
79 | sign_favor = amount["favor"]
80 |
81 | await Text(
82 | inspect.cleandoc(
83 | f"""
84 | 签到成功!
85 | {msg}历史最高 {str(total_days + 1)} 天。
86 | ——————————————
87 | ۞≡==——☚◆☛——==≡۞
88 | ➢[金币+{sign_coin}]
89 | —— Now: {user.coin + sign_coin}
90 | ➢[好感+{sign_favor}]
91 | —— Now: {user.favor + sign_favor}
92 | ➢[经验+{sign_exp}]
93 | —— Now: {user.exp + sign_exp}
94 | ۞≡==——☚◆☛——==≡۞
95 | —————————————
96 | """
97 | )
98 | ).send(at_sender=True)
99 |
100 |
101 | @sign.assign("info")
102 | async def _(bot: Bot, target: Match[At | int], userInfo: UserInfo):
103 | if target.available:
104 | if isinstance(target.result, At):
105 | pid = target.result.target
106 | if not await User.check_exists(bot.adapter.get_name(), pid):
107 | await Text("您@的用户还未注册喔").finish(at_sender=True)
108 | target_id = (await User.get_user_by_pid(pid)).uid
109 | target_user = await User.get_user_by_uid(target_id)
110 | else:
111 | target_id = str(target.result)
112 | target_user = await User.get_user_by_uid(target_id)
113 | call = target_user.user_name
114 | else:
115 | target_id = userInfo.uid
116 | call = "您"
117 |
118 | signInfo = await Sign.get_sign_info(target_id)
119 |
120 | if signInfo is None:
121 | await Text("你还没有签到过喔").finish(at_sender=True)
122 |
123 | await Text(
124 | f"{call}当前连续签到 {signInfo.continuous_days} 天,累计 {signInfo.total_days} 天"
125 | ).send(at_sender=True)
126 | await sign.finish()
127 |
--------------------------------------------------------------------------------
/sora/plugins/sign/config.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from pydantic import field_validator
4 |
5 | from sora.log import logger
6 | from sora.config import BaseConfig
7 |
8 |
9 | class LevelConfig(BaseConfig):
10 | base_exp: int = 150
11 | """初始经验阈值"""
12 | max_level: int = 70
13 | """最大等级"""
14 | cardinality: float = 1.1
15 | """经验系数"""
16 |
17 |
18 | class SignConfig(BaseConfig):
19 | """签到配置"""
20 |
21 | rewards: dict[str, Any] = {"coin": [10, 20], "exp": 320, "favor": 0}
22 |
23 | @field_validator("rewards", mode="before")
24 | def replenish(cls, v):
25 | logger.info("运行 replenish")
26 | if missing_keys := {"coin", "exp", "favor"} - v.keys():
27 | logger.info(f"Missing reward key: {missing_keys}")
28 | for key in missing_keys:
29 | v[key] = 0
30 | return v
31 | return v
32 |
33 | # @validator("rewards")
34 | # def check_rewards(cls, v):
35 | # logger.info("运行 check_rewards")
36 | # if extra_key := v.keys() - {"coin", "exp", "favor"}:
37 | # raise ValueError(f"Invalid reward key: {extra_key}")
38 | # return v
39 |
40 |
41 | class GameConfig(BaseConfig):
42 | level: LevelConfig = LevelConfig()
43 | sign: SignConfig = SignConfig()
44 |
--------------------------------------------------------------------------------
/sora/plugins/sign/utils.py:
--------------------------------------------------------------------------------
1 | from .config import GameConfig
2 |
3 | level_config = (GameConfig.load_config("game")).level
4 |
5 |
6 | def calc_exp_threshold(level: int) -> int:
7 | """
8 | 计算经验阈値
9 | :param level:用户当前的等级
10 | :return: threshold
11 | """
12 | base_exp = level_config.base_exp
13 | cardinality = level_config.cardinality
14 | threshold = round(int(base_exp * (cardinality**level)) / 10) * 10
15 | return threshold
16 |
17 |
18 | def calc_next_exp_threshold(level: int) -> int:
19 | """计算下一等级的经验阈值"""
20 | threshold = calc_exp_threshold(level + 1)
21 | return threshold
22 |
23 |
24 | def generate_progress_bar(user_level: int, user_exp: int, bar_length: int = 20):
25 | current_max_exp = calc_exp_threshold(user_level)
26 | next_max_exp = calc_next_exp_threshold(user_level)
27 |
28 | progress_bar = ""
29 |
30 | for i in range(int(float(current_max_exp / next_max_exp) * bar_length)):
31 | progress_bar = progress_bar + "|"
32 | for i in range(bar_length - len(progress_bar)):
33 | progress_bar = progress_bar + " "
34 | progress_bar = "[" + progress_bar + "]"
35 | progress_text = f"Lv.{user_level + 1}: {user_exp} / {next_max_exp}"
36 | return progress_bar, progress_text
37 |
--------------------------------------------------------------------------------
/sora/plugins/status/__init__.py:
--------------------------------------------------------------------------------
1 | """运行状态"""
2 |
3 | from nonebot import require
4 | from nonebot.rule import to_me
5 |
6 | require("nonebot_plugin_alconna")
7 | from nonebot_plugin_alconna import Command
8 | from nonebot_plugin_alconna.uniseg import UniMessage
9 |
10 | from .drawer import draw
11 |
12 | status = (
13 | Command("status", help_text="查看林汐运行状态")
14 | .usage("@bot /status\n@bot /状态")
15 | .action(lambda: UniMessage.image(raw=draw()))
16 | .build(rule=to_me())
17 | )
18 | status.shortcut("状态", {"prefix": True})
19 | status.shortcut("运行状态", {"prefix": True})
20 |
--------------------------------------------------------------------------------
/sora/plugins/status/color.py:
--------------------------------------------------------------------------------
1 | from typing import Literal, TypeAlias
2 |
3 | Color: TypeAlias = Literal[
4 | "cpu", "ram", "swap", "disk", "nickname", "details", "transparent"
5 | ]
6 |
7 |
8 | cpu_color: tuple[int, int, int, int] = (84, 173, 255, 255)
9 | ram_color: tuple[int, int, int, int] = (255, 179, 204, 255)
10 | swap_color: tuple[int, int, int, int] = (251, 170, 147, 255)
11 | disk_color: tuple[int, int, int, int] = (184, 170, 159, 255)
12 | transparent_color: tuple[int, int, int, int] = (0, 0, 0, 0)
13 | details_color: tuple[int, int, int, int] = (184, 170, 159, 255)
14 | nickname_color: tuple[int, int, int, int] = (84, 173, 255, 255)
15 |
16 |
17 | def get_color(color: Color) -> tuple[int, int, int, int]:
18 | return globals()[f"{color}_color"]
19 |
--------------------------------------------------------------------------------
/sora/plugins/status/drawer.py:
--------------------------------------------------------------------------------
1 | import platform
2 | from io import BytesIO
3 |
4 | import cpuinfo
5 | import nonebot
6 | from PIL import Image, ImageDraw, ImageFont
7 |
8 | from sora.config import bot_config
9 | from sora.version import __version__
10 |
11 | from .model import get_status_info
12 | from .utils import truncate_string
13 | from .path import (
14 | bg_img_path,
15 | adlam_font_path,
16 | baotu_font_path,
17 | marker_img_path,
18 | spicy_font_path,
19 | )
20 | from .color import (
21 | cpu_color,
22 | ram_color,
23 | disk_color,
24 | swap_color,
25 | details_color,
26 | nickname_color,
27 | transparent_color,
28 | )
29 |
30 | system = platform.uname()
31 | nickname = list(bot_config.nickname)[0] if bot_config.nickname else "unknown"
32 |
33 | adlam_fnt = ImageFont.truetype(str(adlam_font_path), 36)
34 | spicy_fnt = ImageFont.truetype(str(spicy_font_path), 38)
35 | baotu_fnt = ImageFont.truetype(str(baotu_font_path), 64)
36 |
37 |
38 | def draw() -> bytes:
39 | """绘图"""
40 |
41 | loaded_plugins = nonebot.get_loaded_plugins()
42 |
43 | with Image.open(bg_img_path).convert("RGBA") as base:
44 | img = Image.new("RGBA", base.size, (0, 0, 0, 0))
45 | marker = Image.open(marker_img_path)
46 |
47 | cpu, ram, swap, disk = get_status_info()
48 |
49 | cpu_info = f"{cpu.usage}% - {cpu.freq}Ghz [{cpu.core}core]"
50 | ram_info = f"{ram.usage} / {ram.total} GB"
51 | swap_info = f"{swap.usage} / {swap.total} GB"
52 | disk_info = f"{disk.usage} / {disk.total} GB"
53 |
54 | content = ImageDraw.Draw(img)
55 | content.text((103, 581), nickname, font=baotu_fnt, fill=nickname_color)
56 | content.text((251, 772), cpu_info, font=spicy_fnt, fill=cpu_color)
57 | content.text((251, 927), ram_info, font=spicy_fnt, fill=ram_color)
58 | content.text((251, 1081), swap_info, font=spicy_fnt, fill=swap_color)
59 | content.text((251, 1235), disk_info, font=spicy_fnt, fill=disk_color)
60 |
61 | content.arc(
62 | (103, 724, 217, 838),
63 | start=-90,
64 | end=(cpu.usage * 3.6),
65 | width=115,
66 | fill=cpu_color,
67 | )
68 | content.arc(
69 | (103, 878, 217, 992),
70 | start=-90,
71 | end=(ram.usage * 3.6),
72 | width=115,
73 | fill=ram_color,
74 | )
75 | content.arc(
76 | (103, 1032, 217, 1146),
77 | start=-90,
78 | end=(swap.usage * 3.6),
79 | width=115,
80 | fill=swap_color,
81 | )
82 | content.arc(
83 | (103, 1186, 217, 1300),
84 | start=-90,
85 | end=(disk.usage * 3.6),
86 | width=115,
87 | fill=disk_color,
88 | )
89 |
90 | content.ellipse((108, 729, 212, 833), width=105, fill=transparent_color)
91 | content.ellipse((108, 883, 212, 987), width=105, fill=transparent_color)
92 | content.ellipse((108, 1037, 212, 1141), width=105, fill=transparent_color)
93 | content.ellipse((108, 1192, 212, 1295), width=105, fill=transparent_color)
94 |
95 | content.text(
96 | (352, 1378),
97 | f"{truncate_string(cpuinfo.get_cpu_info()['brand_raw'])}",
98 | font=adlam_fnt,
99 | fill=details_color,
100 | )
101 | content.text(
102 | (352, 1431),
103 | f"{system.system} {system.release}",
104 | font=adlam_fnt,
105 | fill=details_color,
106 | )
107 | content.text((352, 1484), __version__, font=adlam_fnt, fill=details_color)
108 | content.text(
109 | (352, 1537),
110 | f"{len(loaded_plugins)} loaded",
111 | font=adlam_fnt,
112 | fill=details_color,
113 | )
114 |
115 | nickname_length = baotu_fnt.getlength(nickname)
116 | img.paste(marker, (103 + int(nickname_length) + 44, 595), marker)
117 |
118 | out = Image.alpha_composite(base, img)
119 |
120 | byte_io = BytesIO()
121 | out.save(byte_io, format="png")
122 | img_bytes = byte_io.getvalue()
123 |
124 | return img_bytes
125 |
--------------------------------------------------------------------------------
/sora/plugins/status/model.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 |
3 | import psutil
4 |
5 |
6 | @dataclass
7 | class CPUInfo:
8 | core: int
9 | """CPU 物理核心数"""
10 | usage: float
11 | """CPU 占用"""
12 | freq: float
13 | """CPU 的时钟速度(单位:GHz)"""
14 |
15 | @classmethod
16 | def get_cpu_info(cls):
17 | cpu_core = psutil.cpu_count(logical=False)
18 | cpu_usage = psutil.cpu_percent(interval=1)
19 | cpu_freq = round(psutil.cpu_freq().current / 1000, 2)
20 |
21 | return CPUInfo(core=cpu_core, usage=cpu_usage, freq=cpu_freq)
22 |
23 |
24 | @dataclass
25 | class RAMInfo:
26 | """RAM 信息(单位:GB)"""
27 |
28 | total: int
29 | """RAM 总量"""
30 | usage: float
31 | """当前 RAM 占用"""
32 |
33 | @classmethod
34 | def get_ram_info(cls):
35 | ram_total = round(psutil.virtual_memory().total / (1024**3), 2)
36 | ram_usage = round(psutil.virtual_memory().used / (1024**3), 2)
37 |
38 | return RAMInfo(total=ram_total, usage=ram_usage)
39 |
40 |
41 | @dataclass
42 | class SwapMemory:
43 | """Swap 信息(单位:GB)"""
44 |
45 | total: float
46 | """Swap 总量"""
47 | usage: float
48 | """当前 Swap 占用"""
49 |
50 | @classmethod
51 | def get_swap_info(cls):
52 | swap_total = round(psutil.swap_memory().total / (1024**3), 2)
53 | swap_usage = round(psutil.swap_memory().used / (1024**3), 2)
54 |
55 | return SwapMemory(total=swap_total, usage=swap_usage)
56 |
57 |
58 | @dataclass
59 | class DiskInfo:
60 | """硬盘信息"""
61 |
62 | total: float
63 | """硬盘总量"""
64 | usage: float
65 | """当前硬盘占用"""
66 |
67 | @classmethod
68 | def get_disk_info(cls):
69 | disk_total = round(psutil.disk_usage("/").total / (1024**3), 2)
70 | disk_usage = round(psutil.disk_usage("/").used / (1024**3), 2)
71 |
72 | return DiskInfo(total=disk_total, usage=disk_usage)
73 |
74 |
75 | def get_status_info() -> tuple[CPUInfo, RAMInfo, SwapMemory, DiskInfo]:
76 | """获取 `CPU` `RAM` `SWAP` `DISK` 信息"""
77 | cpu_info = CPUInfo.get_cpu_info()
78 | ram_info = RAMInfo.get_ram_info()
79 | swap_info = SwapMemory.get_swap_info()
80 | disk_info = DiskInfo.get_disk_info()
81 |
82 | return cpu_info, ram_info, swap_info, disk_info
83 |
--------------------------------------------------------------------------------
/sora/plugins/status/path.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from sora.config import FONT_DIR, IMAGE_DIR
4 |
5 | marker_img_path: Path = IMAGE_DIR / "status" / "marker.png"
6 | bg_img_path: Path = IMAGE_DIR / "status" / "background.png"
7 |
8 | baotu_font_path: Path = FONT_DIR / "baotu.ttf"
9 | spicy_font_path: Path = FONT_DIR / "SpicyRice-Regular.ttf"
10 | adlam_font_path: Path = FONT_DIR / "AdlamDisplay-Regular.ttf"
11 |
--------------------------------------------------------------------------------
/sora/plugins/status/utils.py:
--------------------------------------------------------------------------------
1 | def truncate_string(string: str, length: int = 32):
2 | """
3 | 将字符串截断为给定长度。
4 |
5 | 参数:
6 | string (str):要截断的字符串。
7 | length (int):截断字符串的最大长度。
8 |
9 | 返回:
10 | str:截断的字符串。
11 | """
12 | if len(string) > length:
13 | return string[: length - 3] + "..."
14 | else:
15 | return string
16 |
--------------------------------------------------------------------------------
/sora/plugins/user/__init__.py:
--------------------------------------------------------------------------------
1 | import inspect
2 |
3 | import pandas as pd
4 | from nonebot import require
5 | from nonebot.rule import to_me
6 | from nonebot.internal.adapter.bot import Bot
7 | from nonebot.internal.adapter.event import Event
8 |
9 | require("nonebot_plugin_saa")
10 | require("nonebot_plugin_alconna")
11 |
12 | from nonebot_plugin_saa import Text
13 | from arclet.alconna.args import Args
14 | from arclet.alconna.base import Option
15 | from arclet.alconna.core import Alconna
16 | from arclet.alconna.typing import CommandMeta
17 | from nonebot_plugin_alconna import Match, on_alconna
18 | from nonebot_plugin_alconna.uniseg.segment import At
19 |
20 | from sora.log import logger
21 | from sora.utils.api import random_text
22 | from sora.utils.annotated import UserInfo
23 | from sora.database.models import Bind, User
24 |
25 | from .model import token_manager, validate_token
26 |
27 | __usage__ = """
28 | 绑定:@bot /bind
29 | 获取token:@bot /bind -t [自定义token]
30 | 取消绑定:@bot /bind -r
31 | 绑定列表:@bot /bind -l
32 |
33 | (指令别名:/bind)
34 | (注:所有 token 均为 一次性token,使用过后需重新生成)
35 | """
36 |
37 | __example__ = """
38 | 假如您希望在QQ频道绑定群聊中的账号数据,则您应该在群聊中输入
39 | > 用户:@bot /bind -t [可以在此处自定义 token]
40 | > Bot:已为您生成一次性token:sora/123456
41 |
42 | 然后,您需要复制此token,来到QQ频道
43 | > 用户:@bot /bind sora/123456
44 | > Bot:绑定成功
45 | """
46 |
47 | bind = on_alconna(
48 | Alconna(
49 | "bind",
50 | Args["input_token?", str],
51 | Option("-t|--token", Args["token?", str], help_text="生成token"),
52 | Option(
53 | "-l|--list", Args["target?", At], alias={"列表", "信息"}, help_text="查询绑定信息"
54 | ),
55 | Option("-r|--rebind", alias={"取消"}, help_text="取消绑定"),
56 | meta=CommandMeta(
57 | description="将不同平台的用户数据绑定",
58 | usage=__usage__,
59 | example=__example__,
60 | compact=True,
61 | ),
62 | ),
63 | priority=5,
64 | block=True,
65 | rule=to_me(),
66 | )
67 | bind.shortcut("绑定", command="bind", prefix=True)
68 | bind.shortcut("rebind", command="bind --rebind", prefix=True)
69 |
70 | user = on_alconna(
71 | Alconna(
72 | "user",
73 | Args["target?", At | int],
74 | meta=CommandMeta(
75 | description="查询用户信息",
76 | usage="@bot /user [@|uid]",
77 | example="""
78 | @bot /user @Komorebi
79 | @bot /user 123456789
80 | """,
81 | compact=True,
82 | ),
83 | )
84 | )
85 |
86 |
87 | @bind.assign("$main")
88 | async def bind_(userInfo: UserInfo, input_token: Match[str]):
89 | if input_token.available:
90 | validation_result = validate_token(input_token.result)
91 | if validation_result.is_valid and validation_result.uid is not None:
92 | await Bind.bind(origin_uid=userInfo.uid, bind_uid=validation_result.uid)
93 | bindUserInfo = await User.get_user_by_uid(validation_result.uid)
94 | await Text(
95 | inspect.cleandoc(
96 | f"""
97 | 绑定成功\n
98 | 已将 {userInfo.user_name}({userInfo.uid}) 与 {bindUserInfo.user_name}({bindUserInfo.uid}) 绑定
99 | """
100 | )
101 | ).send(at_sender=True)
102 | logger.debug(
103 | f"{userInfo.user_name}({userInfo.uid}) 与 {bindUserInfo.user_name}({bindUserInfo.uid}) 绑定"
104 | )
105 | else:
106 | await Text("绑定失败,密钥错误!").send(at_sender=True)
107 | else:
108 | await Text("格式错误。输入 /help 绑定 查看其详细用法").send(at_sender=True)
109 | token_manager.remove_token(validation_result.uid) # type: ignore
110 |
111 | await bind.finish()
112 |
113 |
114 | @bind.assign("token")
115 | async def token_(user: UserInfo, token: Match[str]):
116 | if token.available:
117 | if token.result == "random":
118 | random_token = random_text(15, prefix="Sora/")
119 | token_manager.add_token(user.uid, random_token)
120 | await Text(f"已为您生成一次性token:{random_token}").send(at_sender=True)
121 | else:
122 | token_manager.add_token(user.uid, token.result)
123 | await Text(f"一次性token设置成功:{token.result}").send(at_sender=True)
124 | else:
125 | random_token = random_text(15, prefix="Sora/")
126 | token_manager.add_token(user.uid, random_token)
127 | await Text(f"已为您生成一次性token:{random_token}").send(at_sender=True)
128 | await bind.finish()
129 |
130 |
131 | @bind.assign("list")
132 | async def bind_list_(event: Event, target: Match[At]):
133 | if target.available:
134 | bindInfo = await Bind.get_user_bind(pid=target.result.target)
135 | df = pd.DataFrame(bindInfo)
136 | await Text(df.to_string(index=False)).send(at_sender=True)
137 | await bind.finish()
138 |
139 | bindInfo = await Bind.get_user_bind(pid=event.get_user_id())
140 | df = pd.DataFrame(bindInfo)
141 | await Text(df.to_string(index=False)).send(at_sender=True)
142 | await bind.finish()
143 |
144 |
145 | @bind.assign("rebind")
146 | async def rebind(bot: Bot, userInfo: UserInfo):
147 | uid = userInfo.uid
148 | origin_uid = (await Bind.get(uid=uid, platform=bot.type)).origin_uid
149 | if origin_uid is None:
150 | await Text("您还未绑定过其他账号").finish(at_sender=True)
151 | await Bind.cancel(origin_uid, uid, platform=bot.type)
152 | await Text("已取消绑定").send(at_sender=True)
153 | await bind.finish()
154 |
155 |
156 | @user.handle()
157 | async def user_(bot: Bot, event: Event, target: Match[At | int]):
158 | if target.available:
159 | if isinstance(target.result, At):
160 | pid = target.result.target
161 | if not await User.check_exists(bot.adapter.get_name(), pid):
162 | await Text("您@的用户还未注册喔").finish(at_sender=True)
163 | target_id = (await User.get_user_by_pid(pid)).uid
164 | target_user = await User.get_user_by_uid(target_id)
165 | else:
166 | target_id = str(target.result)
167 | target_user = await User.get_user_by_uid(target_id)
168 | else:
169 | target_user = await User.get_user_by_event(event)
170 |
171 | bind_info = await Bind.get(uid=target_user.uid, platform=bot.type)
172 |
173 | await Text(
174 | inspect.cleandoc(
175 | f"""
176 | 平台名:{bind_info.platform}
177 | 用户ID:{bind_info.uid}
178 | 平台ID:{bind_info.pid}
179 | 用户名:{target_user.user_name}
180 | 注册时间:{(target_user.register_time).strftime('%Y-%m-%d %H:%M:%S')}
181 | """
182 | )
183 | ).finish()
184 |
--------------------------------------------------------------------------------
/sora/plugins/user/model.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 |
4 | class UserToken(BaseModel):
5 | uid: str
6 | token: str
7 |
8 |
9 | class TokenManager:
10 | def __init__(self):
11 | self.tokens = {}
12 |
13 | def add_token(self, uid: str, token: str):
14 | self.tokens[uid] = token
15 |
16 | def remove_token(self, uid: str):
17 | if uid in self.tokens:
18 | del self.tokens[uid]
19 |
20 | def update_token(self, uid: str, token: str):
21 | if uid in self.tokens:
22 | self.tokens[uid] = token
23 |
24 | def get_token(self, uid: str):
25 | return self.tokens.get(uid)
26 |
27 |
28 | class ValidationResult:
29 | def __init__(self, is_valid: bool, uid: str | None = None):
30 | self.is_valid = is_valid
31 | self.uid = uid
32 |
33 |
34 | def validate_token(token: str) -> ValidationResult:
35 | for uid, user_token in token_manager.tokens.items():
36 | if user_token == token:
37 | return ValidationResult(is_valid=True, uid=uid)
38 | return ValidationResult(is_valid=False)
39 |
40 |
41 | token_manager = TokenManager()
42 |
--------------------------------------------------------------------------------
/sora/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/netsora/SoraBot/fe1d23394157a1570ccc7cf4cbfecee14c53a976/sora/utils/__init__.py
--------------------------------------------------------------------------------
/sora/utils/annotated.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated
2 |
3 | from nonebot.params import Depends
4 |
5 | from sora.database import User as _User
6 |
7 | UserInfo = Annotated[_User, Depends(_User.get_user_by_event)]
8 |
--------------------------------------------------------------------------------
/sora/utils/api.py:
--------------------------------------------------------------------------------
1 | import random
2 | import string
3 | import hashlib
4 | import datetime
5 |
6 |
7 | def md5(text: str) -> str:
8 | """
9 | md5加密
10 |
11 | :param text: 文本
12 | :return: md5加密后的文本
13 | """
14 | md5_ = hashlib.md5()
15 | md5_.update(text.encode())
16 | return md5_.hexdigest()
17 |
18 |
19 | def generate_id():
20 | """
21 | 说明:
22 | 生成九位数用户ID
23 | 结构:
24 | 日期+随机数
25 | """
26 | current_time = datetime.datetime.now().strftime("%Y%m%d") # 获取当前日期
27 | random_number = random.randint(10, 99) # 生成一个随机数
28 | if random_number < 10:
29 | random_number = "0" + str(random_number)
30 | user_id: str = f"{current_time}{random_number}" # 结合日期和随机数生成ID
31 | return user_id
32 |
33 |
34 | def random_hex(length: int, prefix: str | None = None) -> str:
35 | """
36 | 说明:
37 | 生成指定长度的随机字符串
38 | 参数:
39 | * length: 长度
40 | * prefix: 增加前缀(可选)
41 | """
42 | result = hex(random.randint(0, 16**length)).replace("0x", "").upper()
43 | if len(result) < length:
44 | result = "0" * (length - len(result)) + result
45 | if prefix is not None:
46 | result = prefix + result[5:]
47 | return result
48 |
49 |
50 | def random_text(length: int, prefix: str | None = None) -> str:
51 | """
52 | 说明:
53 | 生成指定长度的随机字符串
54 | 参数:
55 | * length: 长度
56 | * prefix: 前缀(可选)
57 | """
58 | result = "".join(random.sample(string.ascii_lowercase + string.digits, length))
59 | if prefix is not None:
60 | result = prefix + result[5:]
61 | return result
62 |
--------------------------------------------------------------------------------
/sora/utils/files.py:
--------------------------------------------------------------------------------
1 | try:
2 | import ujson as json
3 | except ImportError:
4 | import json
5 |
6 | from pathlib import Path
7 | from ssl import SSLCertVerificationError
8 |
9 | from ruamel import yaml
10 |
11 | from .requests import AsyncHttpx
12 |
13 |
14 | def load_json(path: Path | str, encoding: str = "utf-8"):
15 | """
16 | 读取本地json文件,返回文件数据。
17 |
18 | :param path: 文件路径
19 | :param encoding: 编码,默认为utf-8
20 | :return: 数据
21 | """
22 | if isinstance(path, str):
23 | path = Path(path)
24 | if not path.name.endswith(".json"):
25 | path = path.with_suffix(".json")
26 | return json.loads(path.read_text(encoding=encoding)) if path.exists() else {}
27 |
28 |
29 | async def load_json_from_url(
30 | url: str, path: Path | str | None = None, force_refresh: bool = False
31 | ) -> dict:
32 | """
33 | 从网络url中读取json,当有path参数时,如果path文件不存在,就会从url下载保存到path,如果path文件存在,则直接读取path
34 |
35 | :param url: url
36 | :param path: 本地json文件路径
37 | :param force_refresh: 是否强制重新下载
38 | :return: json字典
39 | """
40 | if path and Path(path).exists() and not force_refresh:
41 | return load_json(path=path)
42 | try:
43 | resp = await AsyncHttpx.get(url)
44 | except SSLCertVerificationError:
45 | resp = await AsyncHttpx.get(url.replace("https", "http"))
46 | data = resp.json()
47 | if path and not Path(path).exists():
48 | save_json(data=data, path=path)
49 | return data
50 |
51 |
52 | def save_json(data: dict, path: Path | str | None = None, encoding: str = "utf-8"):
53 | """
54 | 保存json文件
55 |
56 | :param data: json数据
57 | :param path: 保存路径
58 | :param encoding: 编码
59 | """
60 | if isinstance(path, str):
61 | path = Path(path)
62 | if path is not None:
63 | path.parent.mkdir(parents=True, exist_ok=True)
64 | path.write_text(
65 | json.dumps(data, ensure_ascii=False, indent=2), encoding=encoding
66 | )
67 |
68 |
69 | def load_yaml(path: Path | str, encoding: str = "utf-8"):
70 | """
71 | 读取本地yaml文件,返回字典。
72 |
73 | :param path: 文件路径
74 | :param encoding: 编码,默认为utf-8
75 | :return: 字典
76 | """
77 | if isinstance(path, str):
78 | path = Path(path)
79 | return (
80 | yaml.load(path.read_text(encoding=encoding), Loader=yaml.Loader)
81 | if path.exists()
82 | else {}
83 | )
84 |
85 |
86 | def save_yaml(data: dict, path: Path | str | None = None, encoding: str = "utf-8"):
87 | """
88 | 保存yaml文件
89 |
90 | :param data: 数据
91 | :param path: 保存路径
92 | :param encoding: 编码
93 | """
94 | if isinstance(path, str):
95 | path = Path(path)
96 | if path is not None:
97 | path.parent.mkdir(parents=True, exist_ok=True)
98 | with path.open("w", encoding=encoding) as f:
99 | yaml.dump(
100 | data,
101 | f,
102 | indent=2,
103 | Dumper=yaml.RoundTripDumper,
104 | allow_unicode=True,
105 | )
106 |
--------------------------------------------------------------------------------
/sora/utils/helpers.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from nonebot.matcher import Matcher
4 | from nonebot.adapters import Message
5 | from nonebot.params import Depends, EventMessage
6 |
7 |
8 | def extract_image_urls(message: Message) -> list[str]:
9 | """提取消息中的图片链接
10 |
11 | 参数:
12 | message: 消息对象
13 |
14 | 返回:
15 | 图片链接列表
16 | """
17 | return [
18 | segment.data["url"]
19 | for segment in message
20 | if (segment.type == "image") and ("url" in segment.data)
21 | ]
22 |
23 |
24 | CHINESE_CANCELLATION_WORDS = {"算", "别", "不", "停", "取消"}
25 | CHINESE_CANCELLATION_REGEX_1 = re.compile(r"^那?[算别不停]\w{0,3}了?吧?$")
26 | CHINESE_CANCELLATION_REGEX_2 = re.compile(r"^那?(?:[给帮]我)?取消了?吧?$")
27 |
28 |
29 | def is_cancellation(message: Message | str) -> bool:
30 | """判断消息是否表示取消
31 |
32 | 参数:
33 | message: 消息对象或消息文本
34 |
35 | 返回:
36 | 是否表示取消的布尔值
37 | """ """"""
38 | text = message.extract_plain_text() if isinstance(message, Message) else message
39 | return any(kw in text for kw in CHINESE_CANCELLATION_WORDS) and bool(
40 | CHINESE_CANCELLATION_REGEX_1.match(text)
41 | or CHINESE_CANCELLATION_REGEX_2.match(text)
42 | )
43 |
44 |
45 | def HandleCancellation(cancel_prompt: str | None = None) -> bool:
46 | """检查消息是否表示取消`is_cancellation`的依赖注入版本
47 |
48 | 参数:
49 | cancel_prompt: 当消息表示取消时发送给用户的取消消息
50 | """ """"""
51 |
52 | async def dependency(matcher: Matcher, message: Message = EventMessage()) -> bool:
53 | cancelled = is_cancellation(message)
54 | if cancelled and cancel_prompt:
55 | await matcher.finish(cancel_prompt)
56 | return not cancelled
57 |
58 | return Depends(dependency)
59 |
--------------------------------------------------------------------------------
/sora/utils/requests.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from pathlib import Path
3 | from typing import Any, cast
4 | from asyncio.exceptions import TimeoutError
5 |
6 | import httpx
7 | import aiofiles
8 | from retrying import retry
9 | from httpx import Response, ConnectTimeout
10 | from nonebot.adapters.telegram import Bot as TGBot
11 | from rich.progress import (
12 | Progress,
13 | BarColumn,
14 | TextColumn,
15 | DownloadColumn,
16 | TransferSpeedColumn,
17 | )
18 |
19 | from sora.log import logger
20 | from sora.utils.utils import get_local_proxy
21 | from sora.utils.user_agent import get_user_agent
22 |
23 |
24 | class AsyncHttpx:
25 | proxy = {"http://": get_local_proxy(), "https://": get_local_proxy()}
26 |
27 | @classmethod
28 | @retry(stop_max_attempt_number=3)
29 | async def get(
30 | cls,
31 | url: str,
32 | *,
33 | params: dict[str, Any] | None = None,
34 | headers: dict[str, str] | None = None,
35 | cookies: dict[str, str] | None = None,
36 | verify: bool = True,
37 | use_proxy: bool = True,
38 | proxy: dict[str, str] | None = None,
39 | timeout: int | None = 30,
40 | **kwargs,
41 | ) -> Response:
42 | """
43 | 说明:
44 | Get
45 | 参数:
46 | :param url: url
47 | :param params: params
48 | :param headers: 请求头
49 | :param cookies: cookies
50 | :param verify: verify
51 | :param use_proxy: 使用默认代理
52 | :param proxy: 指定代理
53 | :param timeout: 超时时间
54 | """
55 | if not headers:
56 | headers = get_user_agent()
57 |
58 | proxy_ = proxy if proxy else cls.proxy if use_proxy else None
59 | async with httpx.AsyncClient(proxies=proxy_, verify=verify) as client: # type: ignore
60 | return await client.get(
61 | url,
62 | params=params,
63 | headers=headers,
64 | cookies=cookies,
65 | timeout=timeout,
66 | **kwargs,
67 | )
68 |
69 | @classmethod
70 | async def post(
71 | cls,
72 | url: str,
73 | *,
74 | data: dict[str, str] | None = None,
75 | content: Any = None,
76 | files: Any = None,
77 | verify: bool = True,
78 | use_proxy: bool = True,
79 | proxy: dict[str, str] | None = None,
80 | json: dict[str, Any] | None = None,
81 | params: dict[str, str] | None = None,
82 | headers: dict[str, str] | None = None,
83 | cookies: dict[str, str] | None = None,
84 | timeout: int | None = 30,
85 | **kwargs,
86 | ) -> Response:
87 | """
88 | 说明:
89 | Post
90 | 参数:
91 | :param url: url
92 | :param data: data
93 | :param content: content
94 | :param files: files
95 | :param use_proxy: 是否默认代理
96 | :param proxy: 指定代理
97 | :param json: json
98 | :param params: params
99 | :param headers: 请求头
100 | :param cookies: cookies
101 | :param timeout: 超时时间
102 | """
103 | if not headers:
104 | headers = get_user_agent()
105 | proxy_ = proxy if proxy else cls.proxy if use_proxy else None
106 | async with httpx.AsyncClient(proxies=proxy_, verify=verify) as client: # type: ignore
107 | return await client.post(
108 | url,
109 | content=content,
110 | data=data,
111 | files=files,
112 | json=json,
113 | params=params,
114 | headers=headers,
115 | cookies=cookies,
116 | timeout=timeout,
117 | **kwargs,
118 | )
119 |
120 | @classmethod
121 | async def download_file(
122 | cls,
123 | url: str,
124 | path: str | Path,
125 | *,
126 | params: dict[str, str] | None = None,
127 | verify: bool = True,
128 | use_proxy: bool = True,
129 | proxy: dict[str, str] | None = None,
130 | headers: dict[str, str] | None = None,
131 | cookies: dict[str, str] | None = None,
132 | timeout: int | None = 30,
133 | stream: bool = False,
134 | **kwargs,
135 | ) -> bool:
136 | """
137 | 说明:
138 | 下载文件
139 | 参数:
140 | :param url: url
141 | :param path: 存储路径
142 | :param params: params
143 | :param verify: verify
144 | :param use_proxy: 使用代理
145 | :param proxy: 指定代理
146 | :param headers: 请求头
147 | :param cookies: cookies
148 | :param timeout: 超时时间
149 | :param stream: 是否使用流式下载(流式写入+进度条,适用于下载大文件)
150 | """
151 | if isinstance(path, str):
152 | path = Path(path)
153 | path.parent.mkdir(parents=True, exist_ok=True)
154 | try:
155 | for _ in range(3):
156 | if not stream:
157 | try:
158 | content = (
159 | await cls.get(
160 | url,
161 | params=params,
162 | headers=headers,
163 | cookies=cookies,
164 | use_proxy=use_proxy,
165 | proxy=proxy,
166 | timeout=timeout,
167 | **kwargs,
168 | )
169 | ).content
170 | async with aiofiles.open(path, "wb") as wf:
171 | await wf.write(content)
172 | logger.success("请求", f"下载 {url} 成功!Path:{path.absolute()}")
173 | return True
174 | except (TimeoutError, ConnectTimeout):
175 | pass
176 | else:
177 | if not headers:
178 | headers = get_user_agent()
179 | proxy_ = proxy if proxy else cls.proxy if use_proxy else None
180 | try:
181 | async with httpx.AsyncClient(proxies=proxy_, verify=verify) as client: # type: ignore
182 | async with client.stream(
183 | "GET",
184 | url,
185 | params=params,
186 | headers=headers,
187 | cookies=cookies,
188 | timeout=timeout,
189 | **kwargs,
190 | ) as response:
191 | logger.info(
192 | "请求",
193 | f"开始下载 {path.name} 到 Path: {path.absolute()}",
194 | )
195 | async with aiofiles.open(path, "wb") as wf:
196 | total = int(response.headers["Content-Length"])
197 | with Progress(
198 | TextColumn(path.name),
199 | "[progress.percentage]{task.percentage:>3.0f}%",
200 | BarColumn(bar_width=None),
201 | DownloadColumn(),
202 | TransferSpeedColumn(),
203 | ) as progress:
204 | download_task = progress.add_task(
205 | "Download", total=total
206 | )
207 | async for chunk in response.aiter_bytes():
208 | await wf.write(chunk)
209 | await wf.flush()
210 | progress.update(
211 | download_task,
212 | completed=response.num_bytes_downloaded,
213 | )
214 | logger.success(
215 | "请求",
216 | f"下载 {url} 成功!Path:{path.absolute()}",
217 | )
218 | return True
219 | except (TimeoutError, ConnectTimeout):
220 | pass
221 | else:
222 | logger.error("请求", f"下载 {url} 下载超时!Path:{path.absolute()}")
223 | except Exception as e:
224 | logger.error("请求", f"下载 {url} 未知错误 {type(e)}:{e} | Path:{path.absolute()}")
225 | return False
226 |
227 | @classmethod
228 | async def gather_download_file(
229 | cls,
230 | url_list: list[str],
231 | path_list: list[str | Path],
232 | *,
233 | limit_async_number: int | None = None,
234 | params: dict[str, str] | None = None,
235 | use_proxy: bool = True,
236 | proxy: dict[str, str] | None = None,
237 | headers: dict[str, str] | None = None,
238 | cookies: dict[str, str] | None = None,
239 | timeout: int | None = 30,
240 | **kwargs,
241 | ) -> list[bool]:
242 | """
243 | 说明:
244 | 分组同时下载文件
245 | 参数:
246 | :param url_list: url列表
247 | :param path_list: 存储路径列表
248 | :param limit_async_number: 限制同时请求数量
249 | :param params: params
250 | :param use_proxy: 使用代理
251 | :param proxy: 指定代理
252 | :param headers: 请求头
253 | :param cookies: cookies
254 | :param timeout: 超时时间
255 | """
256 | if n := len(url_list) != len(path_list):
257 | raise UrlPathNumberNotEqual(
258 | f"Url数量与Path数量不对等,Url:{len(url_list)},Path:{len(path_list)}"
259 | )
260 | if limit_async_number and n > limit_async_number:
261 | m = float(n) / limit_async_number
262 | x = 0
263 | j = limit_async_number
264 | _split_url_list = []
265 | _split_path_list = []
266 | for _ in range(int(m)):
267 | _split_url_list.append(url_list[x:j])
268 | _split_path_list.append(path_list[x:j])
269 | x += limit_async_number
270 | j += limit_async_number
271 | if int(m) < m:
272 | _split_url_list.append(url_list[j:])
273 | _split_path_list.append(path_list[j:])
274 | else:
275 | _split_url_list = [url_list]
276 | _split_path_list = [path_list]
277 | tasks = []
278 | result_ = []
279 | for x, y in zip(_split_url_list, _split_path_list):
280 | for url, path in zip(x, y):
281 | tasks.append(
282 | asyncio.create_task(
283 | cls.download_file(
284 | url,
285 | path,
286 | params=params,
287 | headers=headers,
288 | cookies=cookies,
289 | use_proxy=use_proxy,
290 | timeout=timeout,
291 | proxy=proxy,
292 | **kwargs,
293 | )
294 | )
295 | )
296 | _x = await asyncio.gather(*tasks) # type: ignore
297 | result_ = result_ + list(_x)
298 | tasks.clear()
299 | return result_
300 |
301 | @classmethod
302 | async def download_telegram_file(
303 | cls,
304 | url: str,
305 | path: str | Path,
306 | bot: TGBot,
307 | ) -> bool:
308 | res = await bot.get_file(file_id=url)
309 | file_path = cast(str, res.file_path)
310 |
311 | turl = f"{bot.bot_config.api_server}file/bot{bot.bot_config.token}/{file_path}"
312 | return await cls.download_file(turl, path)
313 |
314 |
315 | class UrlPathNumberNotEqual(Exception):
316 | pass
317 |
318 |
319 | class BrowserIsNone(Exception):
320 | pass
321 |
--------------------------------------------------------------------------------
/sora/utils/scheduler.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from pydantic import Field, BaseModel
4 | from apscheduler.schedulers.asyncio import AsyncIOScheduler
5 |
6 | from sora import get_driver
7 | from sora.log import LoguruHandler, logger
8 |
9 |
10 | class Config(BaseModel):
11 | apscheduler_autostart: bool = True
12 | apscheduler_log_level: int = 30
13 | apscheduler_config: dict = Field(
14 | default_factory=lambda: {"apscheduler.timezone": "Asia/Shanghai"}
15 | )
16 |
17 | class Config:
18 | extra = "ignore"
19 |
20 |
21 | driver = get_driver()
22 | global_config = driver.config
23 | plugin_config = Config(**global_config.dict())
24 |
25 | scheduler = AsyncIOScheduler()
26 | scheduler.configure(plugin_config.apscheduler_config)
27 |
28 |
29 | async def _start_scheduler():
30 | if not scheduler.running:
31 | scheduler.start()
32 | logger.success("⏱️ Scheduler Started")
33 |
34 |
35 | async def _shutdown_scheduler():
36 | if scheduler.running:
37 | scheduler.shutdown()
38 | logger.opt(colors=True).info("⏱️ Scheduler Shutdown")
39 |
40 |
41 | if plugin_config.apscheduler_autostart:
42 | driver.on_startup(_start_scheduler)
43 | driver.on_shutdown(_shutdown_scheduler)
44 |
45 | aps_logger = logging.getLogger("apscheduler")
46 | aps_logger.setLevel(plugin_config.apscheduler_log_level)
47 | aps_logger.handlers.clear()
48 | aps_logger.addHandler(LoguruHandler())
49 |
--------------------------------------------------------------------------------
/sora/utils/update.py:
--------------------------------------------------------------------------------
1 | import re
2 | from pathlib import Path
3 |
4 | from git.repo import Repo
5 | from nonebot.utils import run_sync
6 | from git.exc import GitCommandError, InvalidGitRepositoryError
7 |
8 | from sora.log import logger
9 | from sora.config import bot_config
10 | from sora.version import __version__
11 |
12 | from .requests import AsyncHttpx
13 |
14 | REPO_COMMITS_URL = "https://api.github.com/repos/netsora/SoraBot/commits"
15 | REPO_RELEASE_URL = "https://api.github.com/repos/netsora/SoraBot/releases"
16 |
17 |
18 | @run_sync
19 | def update():
20 | try:
21 | repo = Repo(Path().absolute())
22 | except InvalidGitRepositoryError:
23 | return "没有发现git仓库,无法通过git更新,请手动下载最新版本的文件进行替换。"
24 | logger.info("更新", "开始执行git pull更新操作")
25 | origin = repo.remotes.origin
26 | try:
27 | origin.pull()
28 | msg = f"""更新完成,版本:{__version__}\n可使用命令 [@bot /重启] 重启{bot_config.nickname}"""
29 | except GitCommandError as e:
30 | if "timeout" in e.stderr or "unable to access" in e.stderr:
31 | msg = "更新失败,连接git仓库超时,请重试或修改源为代理源后再重试。"
32 | elif "Your local changes" in e.stderr:
33 | pyproject_file = Path().parent / "pyproject.toml"
34 | pyproject_raw_content = pyproject_file.read_text(encoding="utf-8")
35 | if raw_plugins_load := re.search(
36 | r"^plugins = \[.+]$", pyproject_raw_content, flags=re.M
37 | ):
38 | pyproject_new_content = pyproject_raw_content.replace(
39 | raw_plugins_load.group(), "plugins = []"
40 | )
41 | logger.info("更新", f"检测到已安装插件:{raw_plugins_load.group()},暂时重置")
42 | else:
43 | pyproject_new_content = pyproject_raw_content
44 | pyproject_file.write_text(pyproject_new_content, encoding="utf-8")
45 | try:
46 | origin.pull()
47 | msg = f"""更新完成,版本:{__version__}\n可使用命令 [@bot /重启] 重启{bot_config.nickname}"""
48 | except GitCommandError as e:
49 | if "timeout" in e.stderr or "unable to access" in e.stderr:
50 | msg = "更新失败,连接git仓库超时,请重试或修改源为代理源后再重试。"
51 | elif " Your local changes" in e.stderr:
52 | msg = f"更新失败,本地修改过文件导致冲突,请解决冲突后再更新。\n{e.stderr}"
53 | else:
54 | msg = f"更新失败,错误信息:{e.stderr},请尝试手动进行更新"
55 | finally:
56 | if raw_plugins_load:
57 | pyproject_new_content = pyproject_file.read_text(encoding="utf-8")
58 | pyproject_new_content = re.sub(
59 | r"^plugins = \[.*]$",
60 | raw_plugins_load.group(),
61 | pyproject_new_content,
62 | )
63 | pyproject_new_content = pyproject_new_content.replace(
64 | "plugins = []", raw_plugins_load.group()
65 | )
66 | pyproject_file.write_text(pyproject_new_content, encoding="utf-8")
67 | logger.info("更新", f"更新结束,还原插件:{raw_plugins_load.group()}")
68 | return msg
69 | else:
70 | msg = f"更新失败,错误信息:{e.stderr},请尝试手动进行更新"
71 | return msg
72 |
73 |
74 | class CheckUpdate:
75 | @staticmethod
76 | async def _get_commits_info() -> dict:
77 | req = await AsyncHttpx.get(REPO_COMMITS_URL)
78 | return req.json()
79 |
80 | @staticmethod
81 | async def _get_release_info() -> dict:
82 | req = await AsyncHttpx.get(REPO_RELEASE_URL)
83 | return req.json()
84 |
85 | @classmethod
86 | async def show_latest_commit_info(cls) -> str:
87 | try:
88 | data = await cls._get_commits_info()
89 | except Exception:
90 | logger.error("更新", "获取最新推送信息失败...")
91 | return ""
92 |
93 | try:
94 | commit_data: dict = data[0]
95 | except Exception:
96 | logger.error("更新", "检查更新失败,频率过高")
97 | return ""
98 |
99 | c_info = commit_data["commit"]
100 | c_msg = c_info["message"]
101 | c_sha = commit_data["sha"][0:5]
102 | c_time = c_info["author"]["date"]
103 |
104 | return f"Latest commit {c_msg} | sha: {c_sha} | time: {c_time}"
105 |
106 | @classmethod
107 | async def show_latest_version(cls) -> tuple:
108 | try:
109 | data = await cls._get_release_info()
110 | except Exception:
111 | logger.error("更新", "获取发布列表失败...")
112 | return "", ""
113 |
114 | try:
115 | release_data: dict = data[0]
116 | except Exception:
117 | logger.error("更新", "检查更新失败,频率过高")
118 | return "", ""
119 |
120 | l_v = release_data["tag_name"]
121 | l_v_t = release_data["published_at"]
122 | return l_v, l_v_t
123 |
--------------------------------------------------------------------------------
/sora/utils/user.py:
--------------------------------------------------------------------------------
1 | import random
2 | import string
3 |
4 |
5 | def generate_password(length=10, chars=string.ascii_letters + string.digits):
6 | """生成用户密码"""
7 |
8 | return "".join([random.choice(chars) for i in range(length)])
9 |
--------------------------------------------------------------------------------
/sora/utils/user_agent.py:
--------------------------------------------------------------------------------
1 | import random
2 |
3 | user_agent = [
4 | "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50", # noqa: E501
5 | "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50",
6 | "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:38.0) Gecko/20100101 Firefox/38.0",
7 | "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; InfoPath.3; rv:11.0) like Gecko", # noqa: E501
8 | "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)",
9 | "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)",
10 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0)",
11 | "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)",
12 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:2.0.1) Gecko/20100101 Firefox/4.0.1",
13 | "Mozilla/5.0 (Windows NT 6.1; rv:2.0.1) Gecko/20100101 Firefox/4.0.1",
14 | "Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; en) Presto/2.8.131 Version/11.11",
15 | "Opera/9.80 (Windows NT 6.1; U; en) Presto/2.8.131 Version/11.11",
16 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_0) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.56 Safari/535.11", # noqa: E501
17 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Maxthon 2.0)",
18 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; TencentTraveler 4.0)",
19 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)",
20 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; The World)",
21 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Trident/4.0; SE 2.X MetaSr 1.0; SE 2.X MetaSr 1.0; .NET CLR 2.0.50727; SE 2.X MetaSr 1.0)", # noqa: E501
22 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; 360SE)",
23 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Avant Browser)",
24 | "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)",
25 | "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", # noqa: E501
26 | "Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", # noqa: E501
27 | "Mozilla/5.0 (iPad; U; CPU OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", # noqa: E501
28 | "Mozilla/5.0 (Linux; U; Android 2.3.7; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", # noqa: E501
29 | "MQQBrowser/26 Mozilla/5.0 (Linux; U; Android 2.3.7; zh-cn; MB200 Build/GRJ22; CyanogenMod-7) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", # noqa: E501
30 | "Opera/9.80 (Android 2.3.4; Linux; Opera Mobi/build-1107180945; U; en-GB) Presto/2.8.149 Version/11.10",
31 | "Mozilla/5.0 (Linux; U; Android 3.0; en-us; Xoom Build/HRI39) AppleWebKit/534.13 (KHTML, like Gecko) Version/4.0 Safari/534.13", # noqa: E501
32 | "Mozilla/5.0 (BlackBerry; U; BlackBerry 9800; en) AppleWebKit/534.1+ (KHTML, like Gecko) Version/6.0.0.337 Mobile Safari/534.1+", # noqa: E501
33 | "Mozilla/5.0 (hp-tablet; Linux; hpwOS/3.0.0; U; en-US) AppleWebKit/534.6 (KHTML, like Gecko) wOSBrowser/233.70 Safari/534.6 TouchPad/1.0", # noqa: E501
34 | "Mozilla/5.0 (SymbianOS/9.4; Series60/5.0 NokiaN97-1/20.0.019; Profile/MIDP-2.1 Configuration/CLDC-1.1) AppleWebKit/525 (KHTML, like Gecko) BrowserNG/7.1.18124", # noqa: E501
35 | "Mozilla/5.0 (compatible; MSIE 9.0; Windows Phone OS 7.5; Trident/5.0; IEMobile/9.0; HTC; Titan)",
36 | "UCWEB7.0.2.37/28/999",
37 | "NOKIA5700/ UCWEB7.0.2.37/28/999",
38 | "Openwave/ UCWEB7.0.2.37/28/999",
39 | "Mozilla/4.0 (compatible; MSIE 6.0; ) Opera/UCWEB7.0.2.37/28/999",
40 | # iPhone 6:
41 | "Mozilla/6.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/8.0 Mobile/10A5376e Safari/8536.25", # noqa: E501
42 | ]
43 |
44 |
45 | def get_user_agent():
46 | return {"User-Agent": random.choice(user_agent)}
47 |
--------------------------------------------------------------------------------
/sora/utils/utils.py:
--------------------------------------------------------------------------------
1 | import httpx
2 |
3 | try:
4 | import ujson as json
5 | except ImportError:
6 | import json
7 |
8 | from nonebot.internal.adapter import Message
9 |
10 | from sora.log import logger
11 | from sora.config import bot_config
12 |
13 |
14 | def get_message_at(data: str) -> list:
15 | """
16 | 获取at列表
17 | :param data: event.json()
18 | """
19 | at_list = []
20 | data_ = json.loads(data)
21 | try:
22 | for msg in data_["message"]:
23 | if msg["type"] == "at":
24 | at_list.append(int(msg["data"]["qq"]))
25 | return at_list
26 | except Exception:
27 | return []
28 |
29 |
30 | async def get_message_img(data: str | Message) -> list[str]:
31 | """
32 | 获取消息中所有的 图片 的链接
33 |
34 | :param data: event.json()
35 | """
36 | img_list = []
37 | if isinstance(data, str):
38 | event = json.loads(data)
39 | if data and (message := event.get("message")):
40 | for msg in message:
41 | if msg["type"] == "image":
42 | img_list.append(msg["data"]["url"])
43 | elif msg["type"] == "attachments":
44 | img_list.append(msg["data"]["url"])
45 | else:
46 | img_list.append(msg["data"]["url"])
47 | else:
48 | if data["image"]:
49 | for seg in data["image"]:
50 | img_list.append(seg.data["url"])
51 | elif data["attachment"]:
52 | for seg in data["attachment"]:
53 | img_list.append(seg.data["url"])
54 | else:
55 | if photo := data["photo"]:
56 | img_list.append(photo[0].data["file"])
57 |
58 | return img_list
59 |
60 |
61 | def get_message_face(data: str | Message) -> list[str]:
62 | """
63 | 获取消息中所有的 face Id
64 |
65 | :param data: event.json()
66 | """
67 | face_list = []
68 | if isinstance(data, str):
69 | event = json.loads(data)
70 | if data and (message := event.get("message")):
71 | for msg in message:
72 | if msg["type"] == "face":
73 | face_list.append(msg["data"]["id"])
74 | else:
75 | for seg in data["face"]:
76 | face_list.append(seg.data["id"])
77 | return face_list
78 |
79 |
80 | def get_message_img_file(data: str | Message) -> list[str]:
81 | """
82 | 获取消息中所有的 图片file
83 |
84 | :param data: event.json()
85 | """
86 | file_list = []
87 | if isinstance(data, str):
88 | event = json.loads(data)
89 | if data and (message := event.get("message")):
90 | for msg in message:
91 | if msg["type"] == "image":
92 | file_list.append(msg["data"]["file"])
93 | else:
94 | for seg in data["image"]:
95 | file_list.append(seg.data["file"])
96 | return file_list
97 |
98 |
99 | def get_message_text(data: str | Message) -> str:
100 | """
101 | 获取消息中 纯文本 的信息
102 |
103 | :param data: event.json()
104 | """
105 | result = ""
106 | if isinstance(data, str):
107 | event = json.loads(data)
108 | if data and (message := event.get("message")):
109 | if isinstance(message, str):
110 | return message.strip()
111 | for msg in message:
112 | if msg["type"] == "text":
113 | result += msg["data"]["text"].strip() + " "
114 | return result.strip()
115 | else:
116 | for seg in data["text"]:
117 | result += seg.data["text"] + " "
118 | return result.strip()
119 |
120 |
121 | def get_local_proxy() -> str | None:
122 | """
123 | 获取 .env* 中设置的代理
124 | """
125 | return bot_config.proxy_url or None
126 |
127 |
128 | async def get_user_avatar(qq: int) -> bytes | None:
129 | """
130 | 快捷获取用户头像(仅支持 v11)
131 | :param qq: qq号
132 | """
133 | url = f"http://q1.qlogo.cn/g?b=qq&nk={qq}&s=160"
134 | async with httpx.AsyncClient() as client:
135 | for _ in range(3):
136 | try:
137 | return (await client.get(url)).content
138 | except TimeoutError:
139 | pass
140 | return None
141 |
142 |
143 | def is_number(s: int | str) -> bool:
144 | """
145 | 说明:
146 | 检测 s 是否为数字
147 | 参数:
148 | * s: 文本
149 | """
150 | if isinstance(s, int):
151 | return True
152 | try:
153 | float(s)
154 | return True
155 | except ValueError:
156 | pass
157 | try:
158 | import unicodedata
159 |
160 | unicodedata.numeric(s)
161 | return True
162 | except (TypeError, ValueError):
163 | pass
164 | return False
165 |
166 |
167 | async def translate(content: str, type: str = "AUTO") -> str:
168 | """
169 | 说明:
170 | 翻译(有道)
171 | 参数:
172 | * type: 类型
173 | * content: 内容
174 |
175 | type的类型有:
176 | * ZH_CN2EN 中文 » 英语
177 | * ZH_CN2JA 中文 » 日语
178 | * ZH_CN2KR 中文 » 韩语
179 | * ZH_CN2FR 中文 » 法语
180 | * ZH_CN2RU 中文 » 俄语
181 | * ZH_CN2SP 中文 » 西语
182 | * EN2ZH_CN 英语 » 中文
183 | * JA2ZH_CN 日语 » 中文
184 | * KR2ZH_CN 韩语 » 中文
185 | * FR2ZH_CN 法语 » 中文
186 | * RU2ZH_CN 俄语 » 中文
187 | * SP2ZH_CN 西语 » 中文
188 | """
189 | url = f"https://fanyi.youdao.com/translate?&doctype=json&type={type}&i={content}"
190 | logger.info("翻译", f"正在翻译{content}")
191 | async with httpx.AsyncClient() as client:
192 | try:
193 | json_data = (await client.get(url)).json()
194 | result = json_data["translateResult"][0][0]["tgt"]
195 | logger.success("翻译", f"翻译成功:{result}")
196 | except TimeoutError:
197 | result = content
198 | logger.error("翻译", "翻译失败,已返回原文")
199 | return result
200 |
--------------------------------------------------------------------------------
/sora/version.py:
--------------------------------------------------------------------------------
1 | from importlib.metadata import metadata
2 |
3 | from pydantic import BaseModel
4 |
5 |
6 | class Metadata(BaseModel):
7 | name: str
8 | version: str
9 | summary: str
10 |
11 | class Config:
12 | extra = "allow"
13 |
14 |
15 | __metadata__ = Metadata(**metadata("sorabot").json) # type: ignore
16 |
17 | __version__ = __metadata__.version
18 |
--------------------------------------------------------------------------------
/tests/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | plugins =
3 | coverage_conditional_plugin
4 |
5 | [report]
6 | exclude_lines =
7 | pragma: no cover
8 | def __repr__
9 | def __str__
10 | @(typing\.)?overload
11 | if (typing\.)?TYPE_CHECKING( is True)?:
12 | @(abc\.)?abstractmethod
13 | raise NotImplementedError
14 | \.\.\.
15 | pass
16 | if __name__ == .__main__.:
17 |
18 | [coverage_conditional_plugin]
19 | rules =
20 | "sys_platform != 'win32'": py-win32
21 | "sys_platform != 'linux'": py-linux
22 | "sys_platform != 'darwin'": py-darwin
23 | "sys_version_info < (3, 9)": py-gte-39
24 | "sys_version_info < (3, 11)": py-gte-311
25 | "sys_version_info >= (3, 11)": py-lt-311
26 |
--------------------------------------------------------------------------------