first
This commit is contained in:
141
.gitignore
vendored
Normal file
141
.gitignore
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
pytestdebug.log
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
doc/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||
22
base_plugins/__init__.py
Normal file
22
base_plugins/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import os
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
def load_all_plugins():
|
||||
"""扫描并加载当前包下所有的插件(支持文件和文件夹)"""
|
||||
package_name = __package__
|
||||
package_path = [os.path.dirname(__file__)]
|
||||
|
||||
print(f" 正在从 {package_name} 加载插件...")
|
||||
|
||||
for loader, module_name, is_pkg in pkgutil.iter_modules(package_path):
|
||||
full_module_name = f"{package_name}.{module_name}"
|
||||
|
||||
try:
|
||||
importlib.import_module(full_module_name)
|
||||
type_str = "包" if is_pkg else "文件"
|
||||
print(f" [{type_str}] 成功加载: {module_name}")
|
||||
except Exception as e:
|
||||
print(f" 加载插件 {module_name} 失败: {e}")
|
||||
|
||||
load_all_plugins()
|
||||
7
config.toml
Normal file
7
config.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[napcat_ws]
|
||||
uri = "ws://127.0.0.1:30004"
|
||||
token = "12333"
|
||||
reconnect_interval = 5
|
||||
|
||||
[bot]
|
||||
command = ["/"]
|
||||
5
core/__init__.py
Normal file
5
core/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .ws import WS
|
||||
from .command_manager import matcher
|
||||
from .config_loader import global_config
|
||||
|
||||
__all__ = ["WS", "matcher", "global_config"]
|
||||
55
core/command_manager.py
Normal file
55
core/command_manager.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from typing import Any
|
||||
|
||||
|
||||
import inspect
|
||||
from .config_loader import global_config
|
||||
|
||||
comm = global_config.bot.get("command")
|
||||
|
||||
class CommandManager:
|
||||
def __init__(self, prefixes=(tuple[Any, ...] (comm))):
|
||||
self.prefixes = prefixes
|
||||
self.commands = {} # 存储指令函数
|
||||
|
||||
def command(self, name: str):
|
||||
"""装饰器:注册指令"""
|
||||
def decorator(func):
|
||||
self.commands[name] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
async def handle_message(self, bot, event):
|
||||
"""解析并分发指令"""
|
||||
raw_text = event.raw_message.strip()
|
||||
|
||||
# 1. 检查前缀
|
||||
prefix_found = None
|
||||
for p in self.prefixes:
|
||||
if raw_text.startswith(p):
|
||||
prefix_found = p
|
||||
break
|
||||
|
||||
if not prefix_found:
|
||||
return # 不是指令,跳过
|
||||
|
||||
# 2. 拆分指令和参数
|
||||
full_cmd = raw_text[len(prefix_found):].split()
|
||||
if not full_cmd:
|
||||
return
|
||||
|
||||
cmd_name = full_cmd[0]
|
||||
args = full_cmd[1:]
|
||||
|
||||
# 3. 查找并执行
|
||||
if cmd_name in self.commands:
|
||||
func = self.commands[cmd_name]
|
||||
# 自动注入参数 (判断函数是否需要 args)
|
||||
sig = inspect.signature(func)
|
||||
if "args" in sig.parameters:
|
||||
await func(bot, event, args)
|
||||
else:
|
||||
await func(bot, event)
|
||||
|
||||
# 实例化全局管理器
|
||||
qianzhui = global_config.bot.get("command")
|
||||
matcher = CommandManager(prefixes=(tuple[Any, ...] (comm)))
|
||||
38
core/config_loader.py
Normal file
38
core/config_loader.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
class Config:
|
||||
def __init__(self, file_path: str = "config.toml"):
|
||||
self.path = Path(file_path)
|
||||
self._data: Dict[str, Any] = {}
|
||||
self.load()
|
||||
def load(self):
|
||||
if not self.path.exists():
|
||||
raise FileNotFoundError(f"配置文件 {self.path} 未找到!")
|
||||
|
||||
with open(self.path, "rb") as f:
|
||||
self._data = tomllib.load(f)
|
||||
|
||||
# 通过属性访问配置
|
||||
@property
|
||||
def napcat_ws(self) -> dict:
|
||||
return self._data.get("napcat_ws", {})
|
||||
|
||||
@property
|
||||
def bot(self) -> dict:
|
||||
return self._data.get("bot", {})
|
||||
|
||||
@property
|
||||
def features(self) -> dict:
|
||||
return self._data.get("features", {})
|
||||
|
||||
# 实例化全局配置对象
|
||||
global_config = Config()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(global_config.napcat_ws)
|
||||
print(global_config.bot.get("command"))
|
||||
print(type(global_config.bot.get("command")) is list)
|
||||
print(global_config.features)
|
||||
|
||||
112
core/ws.py
Normal file
112
core/ws.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
import websockets
|
||||
import traceback
|
||||
from .command_manager import matcher
|
||||
from .config_loader import global_config
|
||||
from models import Event
|
||||
|
||||
class WS:
|
||||
def __init__(self):
|
||||
# 读取参数
|
||||
cfg = global_config.napcat_ws
|
||||
self.url = cfg.get("uri")
|
||||
self.token = cfg.get("token")
|
||||
self.reconnect_interval = cfg.get("reconnect_interval", 5)
|
||||
|
||||
self.ws = None # 存储当前的活跃连接
|
||||
self._pending_requests = {} # 存储等待 API 返回的 Future 对象
|
||||
|
||||
async def connect(self):
|
||||
"""主连接循环:负责建立连接并处理断线重连"""
|
||||
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
||||
|
||||
while True:
|
||||
try:
|
||||
print(f" 正在尝试连接至 NapCat: {self.url}")
|
||||
async with websockets.connect(self.url, additional_headers=headers) as websocket:
|
||||
self.ws = websocket
|
||||
print(" 连接成功!")
|
||||
|
||||
# 进入阻塞式的监听循环
|
||||
await self._listen_loop(websocket)
|
||||
|
||||
except (websockets.exceptions.ConnectionClosed, ConnectionRefusedError) as e:
|
||||
print(f" 连接断开或服务器拒绝访问: {e}")
|
||||
except Exception as e:
|
||||
print(f" 运行异常: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
print(f" {self.reconnect_interval}秒后尝试重连...")
|
||||
await asyncio.sleep(self.reconnect_interval)
|
||||
|
||||
async def _listen_loop(self, websocket):
|
||||
"""核心监听循环:负责从 WebSocket 读取原始数据并分类分发"""
|
||||
async for message in websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
|
||||
# 1. 优先处理 API 响应 (带有 echo 字段)
|
||||
echo_id = data.get("echo")
|
||||
if echo_id and echo_id in self._pending_requests:
|
||||
future = self._pending_requests.pop(echo_id)
|
||||
if not future.done():
|
||||
future.set_result(data) # 唤醒对应的 call_api 函数
|
||||
continue # 处理完 API 响应后跳过本次循环
|
||||
|
||||
# 2. 处理上报的事件 (含有 post_type 字段)
|
||||
if "post_type" in data:
|
||||
# 使用 create_task 异步执行,确保复杂的业务逻辑不阻塞消息接收
|
||||
asyncio.create_task(self.on_event(data))
|
||||
|
||||
except Exception as e:
|
||||
print(f" 解析消息异常: {e}")
|
||||
|
||||
async def on_event(self, raw_data: dict):
|
||||
"""事件分发层:将原始字典转换为 Event 对象并交给 matcher"""
|
||||
# 仅处理消息事件 (message),忽略元事件 (meta_event) 或请求事件 (request)
|
||||
if raw_data.get("post_type") != "message":
|
||||
return
|
||||
|
||||
try:
|
||||
# 将字典解析为强类型的 Event 对象
|
||||
event = Event.from_dict(raw_data)
|
||||
|
||||
# 调试日志:可以看到收到的每条指令内容
|
||||
print(f" 收到消息: [{event.user_id}] -> {event.raw_message}")
|
||||
|
||||
# 调用插件系统的入口函数
|
||||
await matcher.handle_message(self, event)
|
||||
|
||||
except Exception as e:
|
||||
print(f" 事件分发失败: {e}")
|
||||
|
||||
async def call_api(self, action: str, params: dict = None):
|
||||
"""公有 API:供插件调用,发送指令并异步等待结果"""
|
||||
if not self.ws or self.ws.closed:
|
||||
return {"status": "failed", "msg": "websocket not connected"}
|
||||
|
||||
# 创建唯一的 echo ID
|
||||
echo_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
"action": action,
|
||||
"params": params or {},
|
||||
"echo": echo_id
|
||||
}
|
||||
|
||||
# 创建一个 Future 对象用于等待返回结果
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.create_future()
|
||||
self._pending_requests[echo_id] = future
|
||||
|
||||
# 通过 WebSocket 发送请求
|
||||
await self.ws.send(json.dumps(payload))
|
||||
|
||||
try:
|
||||
# 设置 100 秒超时,防止 API 请求永久挂起
|
||||
return await asyncio.wait_for(future, timeout=100.0)
|
||||
except asyncio.TimeoutError:
|
||||
self._pending_requests.pop(echo_id, None)
|
||||
return {"status": "failed", "retcode": -1, "msg": "api timeout"}
|
||||
10
main.py
Normal file
10
main.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# main.py
|
||||
import asyncio
|
||||
from core import WS
|
||||
|
||||
async def main():
|
||||
bot = WS()
|
||||
await bot.connect()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
4
models/__init__.py
Normal file
4
models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .event import Event, MessageSegment
|
||||
from .sender import Sender
|
||||
|
||||
__all__ = ["Event", "MessageSegment", "Sender"]
|
||||
0
models/base.py
Normal file
0
models/base.py
Normal file
63
models/event.py
Normal file
63
models/event.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Dict, Any
|
||||
from .sender import Sender # 导入上面的 Sender
|
||||
|
||||
@dataclass
|
||||
class MessageSegment:
|
||||
type: str
|
||||
data: Dict[str, Any]
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""如果是文本段,返回文本内容,否则返回空字符串"""
|
||||
return self.data.get("text", "") if self.type == "text" else ""
|
||||
|
||||
@property
|
||||
def image_url(self) -> str:
|
||||
"""如果是图片段,返回图片 URL"""
|
||||
return self.data.get("url", "") if self.type == "image" else ""
|
||||
|
||||
def is_at(self, user_id: int = None) -> bool:
|
||||
"""判断是否是 @某人"""
|
||||
if self.type != "at":
|
||||
return False
|
||||
if user_id is None:
|
||||
return True
|
||||
return str(self.data.get("qq")) == str(user_id)
|
||||
|
||||
def __repr__(self):
|
||||
return f"[MS:{self.type}:{self.data}]"
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
post_type: str
|
||||
message_type: str # group 或 private
|
||||
user_id: int
|
||||
self_id: int
|
||||
raw_message: str
|
||||
message: List[MessageSegment]
|
||||
sender: Sender
|
||||
time: int
|
||||
group_id: Optional[int] = None
|
||||
target_id: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict):
|
||||
raw_msg_array = data.get("message", [])
|
||||
segments = [
|
||||
MessageSegment(type=seg["type"], data=seg["data"])
|
||||
for seg in raw_msg_array
|
||||
]
|
||||
|
||||
data_copy = data.copy()
|
||||
data_copy["message"] = segments
|
||||
|
||||
sender_data = data.get("sender", {})
|
||||
sender_obj = Sender(**{k: v for k, v in sender_data.items() if k in Sender.__annotations__})
|
||||
|
||||
data_copy = data.copy()
|
||||
data_copy["message"] = segments
|
||||
data_copy["sender"] = sender_obj # 关键点:把对象塞进去
|
||||
|
||||
valid_data = {k: v for k, v in data_copy.items() if k in cls.__annotations__}
|
||||
return cls(**valid_data)
|
||||
8
models/sender.py
Normal file
8
models/sender.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class Sender:
|
||||
user_id: int
|
||||
nickname: str
|
||||
card: str = ""
|
||||
role: str = "" # admin, owner, member
|
||||
BIN
requirements.txt
Normal file
BIN
requirements.txt
Normal file
Binary file not shown.
Reference in New Issue
Block a user