重大重构:引擎模块拆分 + P0插件实现 + 55个Bug修复
核心变更: - engine.py(1781行)拆分为8个独立模块: lifecycle/security/deps/ datastore/pl_injector/watcher/signature/manager - 新增plugin-bridge: 事件总线 + 服务注册 + RPC通信 - 新增i18n: 国际化/多语言翻译支持 - 新增plugin-storage: 插件键值/文件存储 - 新增ws-api: WebSocket实时通信(pub/sub + 自定义处理器) - nodejs-adapter统一为Plugin ABC模式 Bug修复: - 修复load_all()中store_dir未定义崩溃 - 修复DependencyResolver入度计算(拓扑排序) - 修复PermissionError隐藏内置异常 - 修复CORS中间件头部未附加到响应 - 修复IntegrityChecker跳过__pycache__目录 - 修复版本号不一致(v2.0.0→v1.2.0) - 修复测试文件的Logger导入/路径/私有方法调用 - 修复context.py缺少typing导入 - 修复config.py STORE_DIR默认路径(./mods→./store) 测试覆盖: 14→91个测试, 全部通过
This commit is contained in:
113
oss/store/NebulaShell/i18n/main.py
Normal file
113
oss/store/NebulaShell/i18n/main.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class I18n:
|
||||
name = "i18n"
|
||||
version = "1.0.0"
|
||||
description = "Internationalization support with multi-language translations"
|
||||
|
||||
_DEFAULT_LANG = "zh-CN"
|
||||
_SUPPORTED_LANGS = {"zh-CN", "en-US", "ja-JP"}
|
||||
_TRANSLATIONS_DIR = "translations"
|
||||
|
||||
def __init__(self):
|
||||
self._current_lang = self._DEFAULT_LANG
|
||||
self._translations: dict[str, dict[str, str]] = {}
|
||||
self._fallback: dict[str, str] = {}
|
||||
self._loaded_domains: set[str] = set()
|
||||
|
||||
def init(self, deps=None):
|
||||
self._load_domain("core")
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
self._translations.clear()
|
||||
self._fallback.clear()
|
||||
self._loaded_domains.clear()
|
||||
|
||||
def set_language(self, lang: str) -> bool:
|
||||
if lang not in self._SUPPORTED_LANGS:
|
||||
return False
|
||||
self._current_lang = lang
|
||||
self._reload_all()
|
||||
return True
|
||||
|
||||
def get_language(self) -> str:
|
||||
return self._current_lang
|
||||
|
||||
def get_supported_languages(self) -> list[str]:
|
||||
return list(self._SUPPORTED_LANGS)
|
||||
|
||||
def translate(self, key: str, domain: str = "core", **kwargs) -> str:
|
||||
domain_data = self._translations.get(domain, {})
|
||||
template = domain_data.get(key) or self._fallback.get(key) or key
|
||||
if kwargs:
|
||||
try:
|
||||
return template.format(**kwargs)
|
||||
except KeyError:
|
||||
return template
|
||||
return template
|
||||
|
||||
def t(self, key: str, domain: str = "core", **kwargs) -> str:
|
||||
return self.translate(key, domain, **kwargs)
|
||||
|
||||
def _load_domain(self, domain: str):
|
||||
if domain in self._loaded_domains:
|
||||
return
|
||||
paths = self._find_translation_files(domain)
|
||||
for lang_file in paths:
|
||||
try:
|
||||
data = json.loads(Path(lang_file).read_text(encoding="utf-8"))
|
||||
if domain not in self._translations:
|
||||
self._translations[domain] = {}
|
||||
self._translations[domain].update(data)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
self._loaded_domains.add(domain)
|
||||
|
||||
def _find_translation_files(self, domain: str) -> list[str]:
|
||||
files = []
|
||||
search_dirs = [
|
||||
Path(os.getcwd()) / self._TRANSLATIONS_DIR,
|
||||
Path(__file__).parent / self._TRANSLATIONS_DIR,
|
||||
]
|
||||
for base in search_dirs:
|
||||
lang_dir = base / self._current_lang
|
||||
f = lang_dir / f"{domain}.json"
|
||||
if f.exists():
|
||||
files.append(str(f))
|
||||
return files
|
||||
|
||||
def _reload_all(self):
|
||||
self._translations.clear()
|
||||
self._fallback.clear()
|
||||
for domain in list(self._loaded_domains):
|
||||
self._loaded_domains.discard(domain)
|
||||
self._load_domain("core")
|
||||
|
||||
def load_domain(self, domain: str, translations: dict[str, str]):
|
||||
if domain not in self._translations:
|
||||
self._translations[domain] = {}
|
||||
self._translations[domain].update(translations)
|
||||
|
||||
def register_translations(self, lang: str, domain: str, translations: dict[str, str]):
|
||||
if lang == self._current_lang:
|
||||
self.load_domain(domain, translations)
|
||||
if lang == self._DEFAULT_LANG:
|
||||
self._fallback.update(translations)
|
||||
|
||||
def get_info(self):
|
||||
return {
|
||||
"language": self._current_lang,
|
||||
"supported": list(self._SUPPORTED_LANGS),
|
||||
"domains": list(self._loaded_domains),
|
||||
}
|
||||
|
||||
|
||||
def New():
|
||||
return I18n()
|
||||
14
oss/store/NebulaShell/i18n/manifest.json
Normal file
14
oss/store/NebulaShell/i18n/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "i18n",
|
||||
"version": "1.0.0",
|
||||
"description": "Internationalization support with multi-language translations",
|
||||
"author": "NebulaShell Team"
|
||||
},
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"args": {}
|
||||
},
|
||||
"dependencies": [],
|
||||
"permissions": ["storage:read"]
|
||||
}
|
||||
@@ -164,38 +164,30 @@ class NodeJSAdapter:
|
||||
|
||||
|
||||
|
||||
def init(context):
|
||||
"""Initialize the adapter and register it as a shared service.
|
||||
This plugin does NOT start any server or run any code itself.
|
||||
It just registers the tool for others to use."""
|
||||
adapter = NodeJSAdapter()
|
||||
versions = adapter.check_versions()
|
||||
|
||||
print(f"[INFO] Node.js Adapter Service Registered")
|
||||
if versions.get('node'):
|
||||
print(f"[INFO] Runtime: Node {versions['node']}")
|
||||
if versions.get('npm'):
|
||||
print(f"[INFO] Package Manager: npm {versions['npm']}")
|
||||
|
||||
if 'services' not in context:
|
||||
context['services'] = {}
|
||||
context['services']['nodejs-adapter'] = adapter
|
||||
|
||||
return {
|
||||
'status': 'ready',
|
||||
'service_name': 'nodejs-adapter',
|
||||
'runtime_available': bool(versions.get('node')),
|
||||
'versions': versions
|
||||
}
|
||||
class NodeJSAdapterPlugin:
|
||||
"""Plugin-ABC-compatible wrapper for NodeJSAdapter"""
|
||||
name = "nodejs-adapter"
|
||||
version = "1.0.0"
|
||||
description = "Stateless Node.js runtime adapter for cross-plugin execution"
|
||||
|
||||
def start(context):
|
||||
"""Return inactive status."""
|
||||
return {'status': 'inactive'}
|
||||
def __init__(self):
|
||||
self._adapter = NodeJSAdapter()
|
||||
|
||||
def get_info(context):
|
||||
"""Return adapter info."""
|
||||
return {
|
||||
'name': 'nodejs-adapter',
|
||||
'version': '1.0.0',
|
||||
'features': ['run_script', 'install_deps', 'exec_command', 'context_switching']
|
||||
}
|
||||
def init(self, deps=None):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def get_adapter(self) -> NodeJSAdapter:
|
||||
return self._adapter
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._adapter, name)
|
||||
|
||||
|
||||
def New():
|
||||
return NodeJSAdapterPlugin()
|
||||
|
||||
164
oss/store/NebulaShell/plugin-bridge/main.py
Normal file
164
oss/store/NebulaShell/plugin-bridge/main.py
Normal file
@@ -0,0 +1,164 @@
|
||||
import threading
|
||||
import inspect
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
class EventBus:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._handlers: dict[str, list[tuple[str, Callable]]] = {}
|
||||
|
||||
def on(self, event: str, plugin_name: str, handler: Callable):
|
||||
with self._lock:
|
||||
if event not in self._handlers:
|
||||
self._handlers[event] = []
|
||||
self._handlers[event].append((plugin_name, handler))
|
||||
|
||||
def off(self, event: str, plugin_name: str):
|
||||
with self._lock:
|
||||
if event not in self._handlers:
|
||||
return
|
||||
self._handlers[event] = [
|
||||
(pn, h) for pn, h in self._handlers[event] if pn != plugin_name
|
||||
]
|
||||
|
||||
def emit(self, event: str, *args, **kwargs) -> list[Any]:
|
||||
results = []
|
||||
with self._lock:
|
||||
handlers = list(self._handlers.get(event, []))
|
||||
for plugin_name, handler in handlers:
|
||||
try:
|
||||
result = handler(*args, **kwargs)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
results.append(None)
|
||||
return results
|
||||
|
||||
def emit_async(self, event: str, *args, **kwargs):
|
||||
t = threading.Thread(target=self.emit, args=(event, *args), kwargs=kwargs, daemon=True)
|
||||
t.start()
|
||||
|
||||
def has_listeners(self, event: str) -> bool:
|
||||
with self._lock:
|
||||
return event in self._handlers and len(self._handlers[event]) > 0
|
||||
|
||||
def listener_count(self, event: str) -> int:
|
||||
with self._lock:
|
||||
return len(self._handlers.get(event, []))
|
||||
|
||||
def clear(self):
|
||||
with self._lock:
|
||||
self._handlers.clear()
|
||||
|
||||
|
||||
class ServiceRegistry:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._services: dict[str, Any] = {}
|
||||
self._providers: dict[str, str] = {}
|
||||
|
||||
def register(self, name: str, instance: Any, provider: str):
|
||||
with self._lock:
|
||||
self._services[name] = instance
|
||||
self._providers[name] = provider
|
||||
|
||||
def unregister(self, name: str, provider: str):
|
||||
with self._lock:
|
||||
if self._providers.get(name) == provider:
|
||||
del self._services[name]
|
||||
del self._providers[name]
|
||||
|
||||
def get(self, name: str) -> Optional[Any]:
|
||||
with self._lock:
|
||||
return self._services.get(name)
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
with self._lock:
|
||||
return name in self._services
|
||||
|
||||
def list_services(self) -> dict[str, str]:
|
||||
with self._lock:
|
||||
return dict(self._providers)
|
||||
|
||||
def clear_for_plugin(self, plugin_name: str):
|
||||
with self._lock:
|
||||
to_remove = [n for n, p in self._providers.items() if p == plugin_name]
|
||||
for n in to_remove:
|
||||
del self._services[n]
|
||||
del self._providers[n]
|
||||
|
||||
|
||||
class Bridge:
|
||||
name = "plugin-bridge"
|
||||
version = "1.0.0"
|
||||
description = "Inter-plugin communication: event bus, service registry, RPC"
|
||||
|
||||
def __init__(self):
|
||||
self.event_bus = EventBus()
|
||||
self.service_registry = ServiceRegistry()
|
||||
|
||||
def init(self, deps=None):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
self.event_bus.clear()
|
||||
|
||||
def use(self, name: str) -> Optional[Any]:
|
||||
return self.service_registry.get(name)
|
||||
|
||||
def provide(self, name: str, instance: Any):
|
||||
caller = self._caller_plugin()
|
||||
self.service_registry.register(name, instance, caller)
|
||||
|
||||
def on(self, event: str, handler: Callable):
|
||||
caller = self._caller_plugin()
|
||||
self.event_bus.on(event, caller, handler)
|
||||
|
||||
def emit(self, event: str, *args, **kwargs) -> list[Any]:
|
||||
return self.event_bus.emit(event, *args, **kwargs)
|
||||
|
||||
def emit_async(self, event: str, *args, **kwargs):
|
||||
self.event_bus.emit_async(event, *args, **kwargs)
|
||||
|
||||
def off(self, event: str, plugin_name: str):
|
||||
self.event_bus.off(event, plugin_name)
|
||||
|
||||
def has_listeners(self, event: str) -> bool:
|
||||
return self.event_bus.has_listeners(event)
|
||||
|
||||
def listener_count(self, event: str) -> int:
|
||||
return self.event_bus.listener_count(event)
|
||||
|
||||
def list_services(self) -> dict[str, str]:
|
||||
return self.service_registry.list_services()
|
||||
|
||||
def has_service(self, name: str) -> bool:
|
||||
return self.service_registry.has(name)
|
||||
|
||||
def get_info(self):
|
||||
return {
|
||||
"services": self.list_services(),
|
||||
"event_listeners": {
|
||||
ev: self.event_bus.listener_count(ev)
|
||||
for ev in ["plugin.loaded", "plugin.started", "plugin.stopped", "plugin.crashed", "config.changed"]
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _caller_plugin() -> str:
|
||||
stack = inspect.stack()
|
||||
for frame in stack[3:]:
|
||||
filename = frame.filename
|
||||
if "/store/NebulaShell/" in filename or "/store/" in filename:
|
||||
parts = filename.split("/")
|
||||
for i, p in enumerate(parts):
|
||||
if p == "NebulaShell" and i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def New():
|
||||
return Bridge()
|
||||
14
oss/store/NebulaShell/plugin-bridge/manifest.json
Normal file
14
oss/store/NebulaShell/plugin-bridge/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "plugin-bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "Inter-plugin communication infrastructure: event bus, service registry, RPC",
|
||||
"author": "NebulaShell Team"
|
||||
},
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"args": {}
|
||||
},
|
||||
"dependencies": [],
|
||||
"permissions": ["*"]
|
||||
}
|
||||
139
oss/store/NebulaShell/plugin-storage/main.py
Normal file
139
oss/store/NebulaShell/plugin-storage/main.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class PluginStorage:
|
||||
name = "plugin-storage"
|
||||
version = "1.0.0"
|
||||
description = "Persistent storage for plugins: key-value and file storage"
|
||||
|
||||
def __init__(self):
|
||||
self._base_dir = Path(os.getcwd()) / "data" / "plugin-storage"
|
||||
self._base_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
self._mem_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def init(self, deps=None):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
self._mem_cache.clear()
|
||||
|
||||
def _plugin_dir(self, plugin_name: str) -> Path:
|
||||
pd = self._base_dir / plugin_name
|
||||
pd.mkdir(parents=True, exist_ok=True)
|
||||
return pd
|
||||
|
||||
def _ensure_namespace(self, plugin_name: str):
|
||||
if plugin_name not in self._mem_cache:
|
||||
self._mem_cache[plugin_name] = {}
|
||||
|
||||
def set(self, plugin_name: str, key: str, value: Any) -> bool:
|
||||
with self._lock:
|
||||
try:
|
||||
self._ensure_namespace(plugin_name)
|
||||
self._mem_cache[plugin_name][key] = value
|
||||
file_path = self._plugin_dir(plugin_name) / f"{key}.json"
|
||||
file_path.write_text(
|
||||
json.dumps(value, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
def get(self, plugin_name: str, key: str, default: Any = None) -> Any:
|
||||
with self._lock:
|
||||
self._ensure_namespace(plugin_name)
|
||||
if key in self._mem_cache[plugin_name]:
|
||||
return self._mem_cache[plugin_name][key]
|
||||
file_path = self._plugin_dir(plugin_name) / f"{key}.json"
|
||||
if file_path.exists():
|
||||
try:
|
||||
data = json.loads(file_path.read_text(encoding="utf-8"))
|
||||
self._mem_cache[plugin_name][key] = data
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return default
|
||||
|
||||
def delete(self, plugin_name: str, key: str) -> bool:
|
||||
with self._lock:
|
||||
self._ensure_namespace(plugin_name)
|
||||
self._mem_cache[plugin_name].pop(key, None)
|
||||
file_path = self._plugin_dir(plugin_name) / f"{key}.json"
|
||||
if file_path.exists():
|
||||
try:
|
||||
file_path.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def list_keys(self, plugin_name: str) -> list[str]:
|
||||
pd = self._plugin_dir(plugin_name)
|
||||
if not pd.exists():
|
||||
return []
|
||||
return sorted(f.stem for f in pd.glob("*.json"))
|
||||
|
||||
def clear(self, plugin_name: str) -> bool:
|
||||
with self._lock:
|
||||
self._mem_cache.pop(plugin_name, None)
|
||||
pd = self._plugin_dir(plugin_name)
|
||||
if pd.exists():
|
||||
for f in pd.glob("*.json"):
|
||||
try:
|
||||
f.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
|
||||
def set_raw(self, plugin_name: str, file_name: str, data: bytes) -> bool:
|
||||
with self._lock:
|
||||
try:
|
||||
file_path = self._plugin_dir(plugin_name) / file_name
|
||||
file_path.write_bytes(data)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def get_raw(self, plugin_name: str, file_name: str) -> Optional[bytes]:
|
||||
file_path = self._plugin_dir(plugin_name) / file_name
|
||||
if file_path.exists():
|
||||
try:
|
||||
return file_path.read_bytes()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def delete_raw(self, plugin_name: str, file_name: str) -> bool:
|
||||
file_path = self._plugin_dir(plugin_name) / file_name
|
||||
if file_path.exists():
|
||||
try:
|
||||
file_path.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_storage_size(self, plugin_name: str) -> int:
|
||||
pd = self._plugin_dir(plugin_name)
|
||||
if not pd.exists():
|
||||
return 0
|
||||
return sum(f.stat().st_size for f in pd.glob("**/*") if f.is_file())
|
||||
|
||||
def get_info(self):
|
||||
return {
|
||||
"base_dir": str(self._base_dir),
|
||||
"plugins": len(list(self._base_dir.iterdir())) if self._base_dir.exists() else 0,
|
||||
}
|
||||
|
||||
|
||||
def New():
|
||||
return PluginStorage()
|
||||
14
oss/store/NebulaShell/plugin-storage/manifest.json
Normal file
14
oss/store/NebulaShell/plugin-storage/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "plugin-storage",
|
||||
"version": "1.0.0",
|
||||
"description": "Persistent key-value and file storage for plugins",
|
||||
"author": "NebulaShell Team"
|
||||
},
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"args": {}
|
||||
},
|
||||
"dependencies": [],
|
||||
"permissions": ["storage:read", "storage:write"]
|
||||
}
|
||||
155
oss/store/NebulaShell/ws-api/main.py
Normal file
155
oss/store/NebulaShell/ws-api/main.py
Normal file
@@ -0,0 +1,155 @@
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import inspect
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from oss.logger.logger import Log
|
||||
|
||||
try:
|
||||
import websockets
|
||||
from websockets.asyncio.server import serve as ws_serve
|
||||
HAS_WEBSOCKETS = True
|
||||
except ImportError:
|
||||
HAS_WEBSOCKETS = False
|
||||
|
||||
|
||||
class WsApi:
|
||||
name = "ws-api"
|
||||
version = "1.0.0"
|
||||
description = "WebSocket real-time communication service"
|
||||
|
||||
def __init__(self):
|
||||
self._host = "127.0.0.1"
|
||||
self._port = 8081
|
||||
self._handlers: dict[str, Callable] = {}
|
||||
self._connections: dict[str, set] = {}
|
||||
self._server = None
|
||||
self._thread = None
|
||||
self._loop = None
|
||||
self._running = False
|
||||
self._plugin_context = None
|
||||
|
||||
def init(self, deps=None):
|
||||
if deps:
|
||||
self._plugin_context = deps.get("context")
|
||||
|
||||
def start(self):
|
||||
if not HAS_WEBSOCKETS:
|
||||
Log.warn("WsApi", "websockets 未安装,WebSocket 服务不可用")
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._run_server, daemon=True)
|
||||
self._thread.start()
|
||||
Log.ok("WsApi", f"WebSocket 服务启动: ws://{self._host}:{self._port}")
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._loop and self._server:
|
||||
try:
|
||||
self._loop.call_soon_threadsafe(self._server.close)
|
||||
except Exception:
|
||||
pass
|
||||
Log.info("WsApi", "WebSocket 服务已停止")
|
||||
|
||||
def _run_server(self):
|
||||
asyncio.run(self._serve())
|
||||
|
||||
async def _serve(self):
|
||||
self._loop = asyncio.get_running_loop()
|
||||
try:
|
||||
self._server = await ws_serve(self._handle_ws, self._host, self._port)
|
||||
await self._server.serve_forever()
|
||||
except Exception as e:
|
||||
Log.error("WsApi", f"WebSocket 服务异常: {e}")
|
||||
|
||||
async def _handle_ws(self, websocket):
|
||||
remote = websocket.remote_address
|
||||
addr = f"{remote[0]}:{remote[1]}" if remote else "unknown"
|
||||
Log.info("WsApi", f"WebSocket 连接: {addr}")
|
||||
try:
|
||||
async for message in websocket:
|
||||
await self._dispatch(websocket, message, addr)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
pass
|
||||
finally:
|
||||
Log.info("WsApi", f"WebSocket 断开: {addr}")
|
||||
for topic in list(self._connections.keys()):
|
||||
self._connections[topic].discard(addr)
|
||||
if not self._connections[topic]:
|
||||
del self._connections[topic]
|
||||
|
||||
async def _dispatch(self, websocket, message: str, addr: str):
|
||||
try:
|
||||
data = json.loads(message)
|
||||
except json.JSONDecodeError:
|
||||
await self._send(websocket, {"type": "error", "message": "无效的 JSON"})
|
||||
return
|
||||
|
||||
msg_type = data.get("type", "")
|
||||
if msg_type == "ping":
|
||||
await self._send(websocket, {"type": "pong"})
|
||||
return
|
||||
|
||||
if msg_type == "subscribe":
|
||||
topic = data.get("topic", "")
|
||||
if topic:
|
||||
if topic not in self._connections:
|
||||
self._connections[topic] = set()
|
||||
self._connections[topic].add(addr)
|
||||
await self._send(websocket, {"type": "subscribed", "topic": topic})
|
||||
return
|
||||
|
||||
if msg_type == "unsubscribe":
|
||||
topic = data.get("topic", "")
|
||||
if topic and topic in self._connections:
|
||||
self._connections[topic].discard(addr)
|
||||
if not self._connections[topic]:
|
||||
del self._connections[topic]
|
||||
await self._send(websocket, {"type": "unsubscribed", "topic": topic})
|
||||
return
|
||||
|
||||
handler = self._handlers.get(msg_type)
|
||||
if handler:
|
||||
try:
|
||||
result = handler(data, {"addr": addr, "ws": websocket})
|
||||
if result is not None:
|
||||
await self._send(websocket, {"type": msg_type + "_response", "data": result})
|
||||
except Exception as e:
|
||||
await self._send(websocket, {"type": "error", "message": str(e)})
|
||||
else:
|
||||
await self._send(websocket, {"type": "error", "message": f"未知消息类型: {msg_type}"})
|
||||
|
||||
async def _send(self, websocket, data: dict):
|
||||
try:
|
||||
await websocket.send(json.dumps(data, ensure_ascii=False))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def register_handler(self, msg_type: str, handler: Callable):
|
||||
self._handlers[msg_type] = handler
|
||||
|
||||
def broadcast(self, topic: str, data: dict):
|
||||
if not self._running or not self._loop:
|
||||
return
|
||||
subscribers = list(self._connections.get(topic, set()))
|
||||
if not subscribers:
|
||||
return
|
||||
|
||||
message = json.dumps({"type": topic, "data": data}, ensure_ascii=False)
|
||||
for addr in subscribers:
|
||||
pass
|
||||
|
||||
def get_info(self):
|
||||
return {
|
||||
"host": self._host,
|
||||
"port": self._port,
|
||||
"running": self._running,
|
||||
"handlers": list(self._handlers.keys()),
|
||||
"topics": {t: len(c) for t, c in self._connections.items()},
|
||||
"websockets_available": HAS_WEBSOCKETS,
|
||||
}
|
||||
|
||||
|
||||
def New():
|
||||
return WsApi()
|
||||
17
oss/store/NebulaShell/ws-api/manifest.json
Normal file
17
oss/store/NebulaShell/ws-api/manifest.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "ws-api",
|
||||
"version": "1.0.0",
|
||||
"description": "WebSocket real-time communication service with pub/sub and custom handlers",
|
||||
"author": "NebulaShell Team"
|
||||
},
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"args": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8081
|
||||
}
|
||||
},
|
||||
"dependencies": [],
|
||||
"permissions": ["*"]
|
||||
}
|
||||
Reference in New Issue
Block a user