diff --git "a/\346\234\215\346\234\215\344\272\222\351\200\232\350\201\212\345\244\251/__init__.py" "b/\346\234\215\346\234\215\344\272\222\351\200\232\350\201\212\345\244\251/__init__.py" new file mode 100644 index 00000000..03a1e621 --- /dev/null +++ "b/\346\234\215\346\234\215\344\272\222\351\200\232\350\201\212\345\244\251/__init__.py" @@ -0,0 +1,359 @@ +import asyncio +import json +import threading + +from tooldelta import Config, Plugin, plugin_entry, game_utils +from tooldelta.constants import PacketIDS + + +class CrossServerChat(Plugin): + """服服互通聊天插件 (频道鉴权版)。""" + + name = "服服互通聊天" + author = "哈茶块" + version = (2, 3, 2) + + # 扩展配置,增加鉴权信息 + CONFIG_TEMPLATE = { + "中转服务器地址": "ws://core.aurorabot.top", + "当前服务器名称": "你的服务器名称", + # 默认进入官方公开频道。如需建立私人网络请改为自定义名称 + "频道名称": "全球大厅", + "频道类型(公开/私密)": "公开", + "频道密钥(仅私密需填)": "" + } + + def __init__(self, frame): + """初始化插件,处理热重载。""" + super().__init__(frame) + + if hasattr(self.frame, "_cross_server_chat_instance"): + old_inst = getattr(self.frame, "_cross_server_chat_instance", None) + if old_inst: + try: + old_inst.stop() + except Exception: + pass + self.frame._cross_server_chat_instance = self + + self.is_running = True + + self.cfg, _ = Config.get_plugin_config_and_version( + self.name, + Config.auto_to_std(self.CONFIG_TEMPLATE), + self.CONFIG_TEMPLATE, + self.version + ) + self.ws_conn = None + self.msg_queue = asyncio.Queue() + self.loop = None + + self.ListenPreload(self.on_def) + self.ListenPacket(PacketIDS.Text, self.on_chat) + + def stop(self): + """停止后台服务,供重载时安全清理旧线程。""" + self.is_running = False + if self.ws_conn and self.loop: + try: + asyncio.run_coroutine_threadsafe(self.ws_conn.close(), self.loop) + except Exception: + pass + + def get_online_players(self): + """获取所有在线玩家列表,兼容多核心版本。""" + players = [] + try: + res = game_utils.getTarget("@a") + if isinstance(res, list): + players = res + except Exception: + pass + + if players: + return players + + try: + if hasattr(self.game_ctrl, "players"): + p_list = self.game_ctrl.players.getAllPlayers() + players = [ + p.name if hasattr(p, 'name') else str(p) for p in p_list + ] + except Exception: + pass + + if players: + return players + + try: + has_all = hasattr(self.game_ctrl, "all_players") + if has_all and isinstance(self.game_ctrl.all_players, list): + players = self.game_ctrl.all_players + except Exception: + pass + + return players + + def on_def(self): + """插件预加载时检查依赖并启动线程。""" + try: + self.GetPluginAPI("pip").require("websockets") + except Exception as e: + err_msg = f"无法调用内置 pip 模块检查依赖: {e}" + self.print_err(err_msg) + return + + c_type = self.cfg.get("频道类型(公开/私密)", "公开") + lock_icon = "🔒" if c_type == "私密" else "🌐" + self.print_inf(f"准备连接到频道: {lock_icon} [{self.cfg['频道名称']}]") + threading.Thread(target=self.run_ws_client, daemon=True).start() + + def run_ws_client(self): + """在新线程中初始化异步事件循环。""" + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self.loop.run_until_complete(self.ws_main()) + + async def ws_main(self): + """维持与中转服务器的持久连接。""" + import websockets + + uri = self.cfg["中转服务器地址"] + channel = self.cfg["频道名称"] + + while self.is_running: + try: + self.print_inf(f"正在尝试连接中转服务器: {uri} ...") + async with websockets.connect(uri) as ws: + + # 1. 鉴权握手 + auth_data = { + "type": "auth", + "channel": channel, + "server_name": self.cfg["当前服务器名称"], + "channel_type": self.cfg.get("频道类型(公开/私密)", "公开"), + "channel_key": self.cfg.get("频道密钥(仅私密需填)", "") + } + await ws.send(json.dumps(auth_data)) + + # 2. 等待服务端反馈 (5秒超时) + auth_resp_str = await asyncio.wait_for(ws.recv(), timeout=5.0) + auth_resp = json.loads(auth_resp_str) + + if auth_resp.get("type") == "auth_fail": + self.print_err(f"❌ 互通连接被拒绝: {auth_resp.get('msg')}") + self.print_war("请修改配置后使用 reload 重载插件。") + self.is_running = False # 彻底阻断,防止刷屏重连 + return + + self.ws_conn = ws + self.print_suc("✅ 鉴权通过,互通服务已上线!") + + # 3. 正常建立并发任务 + recv_task = asyncio.create_task(self.ws_recv(ws)) + send_task = asyncio.create_task(self.ws_send(ws)) + report_task = asyncio.create_task(self.status_report_loop(ws)) + + _, pending = await asyncio.wait( + [recv_task, send_task, report_task], + return_when=asyncio.FIRST_COMPLETED + ) + + for task in pending: + task.cancel() + + except Exception as e: + if self.is_running: + self.print_err(f"❌ 与中转服务器连接断开或无法连接: {e}") + finally: + self.ws_conn = None + + if not self.is_running: + break + + if self.is_running: + self.print_war("5秒后尝试重新连接...") + for _ in range(5): + if not self.is_running: + break + await asyncio.sleep(1) + + async def status_report_loop(self, ws): + """定时高频上报本服务器的真实玩家名单给后端缓存。""" + while self.is_running: + try: + local_players = self.get_online_players() + payload = { + "type": "status", + "players": local_players + } + await ws.send(json.dumps(payload)) + except Exception: + pass + await asyncio.sleep(3) + + async def ws_recv(self, ws): + """接收中转服务器发来的跨服消息。""" + async for msg in ws: + if not self.is_running: + break + try: + data = json.loads(msg) + msg_type = data.get("type") + server_name = data.get("server", "未知服务器") + sender = data.get("player", "未知玩家") + content = data.get("msg", "") + + if msg_type == "chat": + fmt_msg = f"§7[§b{server_name}§7] §e{sender} §f> §r{content}" + self.game_ctrl.say_to("@a", fmt_msg) + + elif msg_type == "event": + sub_type = data.get("sub_type") + if sub_type == "reply_list": + target = data.get("target") + reply_content = data.get("content") + self.game_ctrl.say_to(target, reply_content) + + elif sub_type == "private_msg_error": + target = data.get("target") + error_msg = data.get("msg") + self.game_ctrl.say_to(target, error_msg) + + elif msg_type == "private_msg": + target = data.get("target") + local_players = self.get_online_players() + if target in local_players: + fmt_msg = ( + f"§d[私聊] §7[§b{server_name}§7] §e{sender} " + f"§f-> §e你§f: §r{content}" + ) + self.game_ctrl.say_to(target, fmt_msg) + + except Exception as e: + self.print_err(f"处理跨服消息时出错: {e}") + + async def ws_send(self, ws): + """将队列里的本地消息发送给中转服务器。""" + while self.is_running: + try: + msg = await asyncio.wait_for(self.msg_queue.get(), timeout=1.0) + await ws.send(msg) + except asyncio.TimeoutError: + continue + except Exception: + break + + def on_chat(self, pkt): # skipcq: PY-R1000 + """监听本地玩家聊天并处理指令分发。""" + player = pkt.get("SourceName", "") + msg = pkt.get("Message", "").strip() + text_type = pkt.get("TextType", 0) + + if not player or not msg: + return False + if text_type != 1: + return False + if player == self.game_ctrl.bot_name: + return False + + if msg.startswith(".msg ") or msg.startswith(".w "): + cmd_str = msg.split(" ", 1)[1].strip() + target_player = "" + content = "" + + if cmd_str.startswith('"'): + end_idx = cmd_str.find('"', 1) + if end_idx != -1: + target_player = cmd_str[1:end_idx] + content = cmd_str[end_idx+1:].strip() + else: + parts = cmd_str.split(" ", 1) + target_player = parts[0] + content = parts[1] if len(parts) > 1 else "" + else: + parts = cmd_str.split(" ", 1) + if len(parts) < 2: + self.game_ctrl.say_to( + player, + "§c格式错误!用法: .msg <玩家名> <内容> (名字带空格请用双引号)" + ) + return True + target_player = parts[0] + content = parts[1] + + local_players = self.get_online_players() + + if target_player in local_players: + self.game_ctrl.say_to( + target_player, + f"§d[私聊] §7[§b{self.cfg['当前服务器名称']}§7] " + f"§e{player} §f-> §e你§f: §r{content}" + ) + self.game_ctrl.say_to( + player, + f"§d[私聊] §f你 -> §e{target_player}§f: §r{content}" + ) + return True + + if self.is_running and self.loop and self.ws_conn: + data = { + "type": "private_msg", + "server": self.cfg["当前服务器名称"], + "player": player, + "target": target_player, + "msg": content + } + asyncio.run_coroutine_threadsafe( + self.msg_queue.put(json.dumps(data)), + self.loop + ) + self.game_ctrl.say_to( + player, + f"§d[私聊] §f你 -> §e{target_player}§f: §r{content}" + ) + else: + self.game_ctrl.say_to(player, "§c互通服务未连接,无法发送跨服私聊。") + return True + + if msg in [".list", ".在线"]: + if self.is_running and self.loop and self.ws_conn: + data = { + "type": "event", + "sub_type": "request_list", + "requester": player + } + asyncio.run_coroutine_threadsafe( + self.msg_queue.put(json.dumps(data)), + self.loop + ) + else: + local_players = self.get_online_players() + local_str = ", ".join(local_players) + msg_str = ( + f"§e==== 🌐 本服在线: {len(local_players)} 人 ====\n" + f"§7[§b{self.cfg['当前服务器名称']}§7] §f{local_str}\n" + "§c(跨服网络未连接)" + ) + self.game_ctrl.say_to(player, msg_str) + return True + + if msg.startswith("/") or msg.startswith("."): + return False + + if self.is_running and self.loop and self.ws_conn: + data = { + "type": "chat", + "server": self.cfg["当前服务器名称"], + "player": player, + "msg": msg + } + asyncio.run_coroutine_threadsafe( + self.msg_queue.put(json.dumps(data)), + self.loop + ) + + return False + + +entry = plugin_entry(CrossServerChat) diff --git "a/\346\234\215\346\234\215\344\272\222\351\200\232\350\201\212\345\244\251/core_server.py" "b/\346\234\215\346\234\215\344\272\222\351\200\232\350\201\212\345\244\251/core_server.py" new file mode 100644 index 00000000..4376aea0 --- /dev/null +++ "b/\346\234\215\346\234\215\344\272\222\351\200\232\350\201\212\345\244\251/core_server.py" @@ -0,0 +1,640 @@ +import asyncio +import json +import logging +import os +import websockets +from aiohttp import web + +# 配置日志输出格式 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +# 存储所有连接的客户端详细信息 +# 结构: { websocket: {"channel": "大厅", "server_name": "一区", "players": []} } +clients = {} + +META_FILE = "channels_meta.json" + + +def load_channels_meta(): + """从本地加载频道鉴权数据防抢注。""" + if os.path.exists(META_FILE): + with open(META_FILE, "r", encoding="utf-8") as file_obj: + return json.load(file_obj) + return {} + + +# 存储频道元数据 (类型、密钥) +channels_meta = load_channels_meta() + +# 强制锁定官方默认频道,任何人无法抢注更改 +channels_meta["全球大厅"] = {"type": "public", "key": ""} + + +def save_meta(): + """将频道鉴权数据永久保存到本地防抢注。""" + with open(META_FILE, "w", encoding="utf-8") as file_obj: + json.dump(channels_meta, file_obj, ensure_ascii=False, indent=4) + + +# ==================== 🌐 Web UI 模板 ==================== +WEB_UI_HTML = """ + + + + + + 服服互通 - 全网实时监控大屏 + + + + + +
+ + +
+
+

+ + 跨服互通枢纽 + Active +

+

+ Global Network & Authorization Gateway +

+
+
+ Data refreshed {{ lastUpdate }} +
+
+ + +
+
+
+
+
活跃频道数
+
+ {{ Object.keys(channels).length }} +
+
+
+
+
+
+
在线子服数
+
+ {{ totalServers }} +
+
+
+
+
+
+
全网在线玩家
+
+ {{ totalPlayers }} +
+
+
+
+ + +
+
+
+

+ + + + 私密频道 · 子服规模榜 +

+
+
    +
  • +
    + + {{ idx + 1 }} + + + {{ ch.name }} + +
    + + {{ ch.serverCount }} + + 个服务器 + + +
  • +
+
+ +
+
+

+ + + + 私密频道 · 在线人数榜 +

+
+
    +
  • +
    + + {{ idx + 1 }} + + + {{ ch.name }} + +
    + + {{ ch.playerCount }} + + 名玩家 + + +
  • +
+
+
+ + +
+
+

+ # + {{ channelName }} + + + + + 私密 + + + + + + + 公开 + +

+
+
+ +
+
+
+
+ + + {{ server.server_name }} +
+ + {{ server.ip }} + +
+
+
+
+ 在线玩家 +
+
+ {{ server.player_count }} + + 人 + +
+
+ +
+ + 💤 当前服务器无人在线 + + + + + + {{ player }} + +
+
+
+
+
+ +
+ + + +

网络空闲

+

目前没有连接到中转枢纽的子服务器。

+
+ + +
+ + + + +""" + + +async def web_index(_request): + """处理 WebUI 首页请求。""" + return web.Response(text=WEB_UI_HTML, content_type="text/html", charset="utf-8") + + +async def web_status_api(_request): + """处理 WebUI 数据获取 API。""" + data = {} + for ws, info in clients.items(): + chan = info["channel"] + srv = info["server_name"] + players = info["players"] + + ip_addr = "未知 IP" + if ws.remote_address: + ip_addr = f"{ws.remote_address[0]}:{ws.remote_address[1]}" + + if chan not in data: + meta = channels_meta.get(chan, {"type": "public"}) + data[chan] = { + "meta": {"type": meta["type"]}, + "servers": [] + } + + data[chan]["servers"].append({ + "server_name": srv, + "player_count": len(players), + "players": players, + "ip": ip_addr + }) + + return web.json_response(data) + + +async def start_web_server(host, port): + """启动独立运行在同一事件循环内的 Web 服务。""" + app = web.Application() + app.router.add_get("/", web_index) + app.router.add_get("/api/status", web_status_api) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host, port) + await site.start() + logging.info("🌐 WebUI 管理大屏已就绪,请在浏览器访问 http://%s:%d", host, port) + + +async def handle_client(websocket, _path): # skipcq: PY-R1000 + """处理每个客户端连接及数据路由。""" + try: + auth_msg = await websocket.recv() + auth_data = json.loads(auth_msg) + + if auth_data.get("type") != "auth": + logging.warning("非法的连接请求,已拒绝") + return + + channel = auth_data.get("channel", "default") + server_name = auth_data.get("server_name", "未知子服") + c_type = "private" if auth_data.get("channel_type") == "私密" else "public" + c_key = auth_data.get("channel_key", "") + + # ================= 权限校验模块 ================= + if channel in channels_meta: + meta = channels_meta[channel] + if meta["type"] == "private" and meta["key"] != c_key: + reject_msg = {"type": "auth_fail", "msg": "该频道为私密频道,密钥错误!"} + await websocket.send(json.dumps(reject_msg)) + logging.warning( + "拒绝接入: %s 尝试连接私密频道 [%s] 密码错误 (%s)", + server_name, channel, websocket.remote_address + ) + return + else: + # 若是第一个连接的,则永久注册该频道并保存元数据 + channels_meta[channel] = {"type": c_type, "key": c_key} + save_meta() + logging.info("⭐ 新频道永久注册: [%s] (类型: %s)", channel, c_type) + + # 告诉前端验证成功 + await websocket.send(json.dumps({"type": "auth_success"})) + + clients[websocket] = { + "channel": channel, + "server_name": server_name, + "players": [] + } + + logging.info( + "🔗 新子服接入: [%s] (%s) | 当前总节点: %d", + server_name, channel, len(clients) + ) + + # 智能数据路由 + async for message in websocket: + data = json.loads(message) + msg_type = data.get("type") + my_info = clients[websocket] + + if msg_type == "chat": + targets = [ + ws for ws, info in list(clients.items()) + if info["channel"] == my_info["channel"] and ws != websocket + ] + for target_ws in targets: + try: + await target_ws.send(message) + except Exception: + pass + + elif msg_type == "status": + clients[websocket]["players"] = data.get("players", []) + + elif msg_type == "event": + sub_type = data.get("sub_type") + + if sub_type == "request_list": + requester = data.get("requester") + total_players = 0 + servers_data = {} + + for ws, info in clients.items(): + if info["channel"] == my_info["channel"]: + p_list = info["players"] + servers_data[info["server_name"]] = p_list + total_players += len(p_list) + + lines = [f"§e==== 🌐 全网同频道在线: {total_players} 人 ===="] + for srv, p_list in servers_data.items(): + p_str = ", ".join(p_list) if p_list else "§8无人在线" + lines.append(f"§7[§b{srv}§7] §a({len(p_list)}人) §f{p_str}") + + reply_msg = { + "type": "event", + "sub_type": "reply_list", + "target": requester, + "content": "\n".join(lines) + } + try: + await websocket.send(json.dumps(reply_msg)) + except Exception: + pass + + elif msg_type == "private_msg": + target = data.get("target") + sender = data.get("player") + + routed = False + for ws, info in clients.items(): + is_same_channel = info["channel"] == my_info["channel"] + if is_same_channel and target in info["players"]: + try: + await ws.send(message) + routed = True + logging.info( + "✉️ 跨服私聊路由: %s -> %s (投递至 %s)", + sender, target, info["server_name"] + ) + except Exception: + pass + break + + if not routed: + error_msg = { + "type": "event", + "sub_type": "private_msg_error", + "target": sender, + "msg": f"§c跨服私聊失败: 全网未找到玩家 {target} (可能不在线或拼写有误)。" + } + try: + await websocket.send(json.dumps(error_msg)) + except Exception: + pass + + except websockets.exceptions.ConnectionClosed: + pass + except Exception as e: + logging.error("客户端连接异常: %s", e) + finally: + # 断开清理逻辑 + if websocket in clients: + info = clients[websocket] + chan = info["channel"] + del clients[websocket] + logging.info( + "❌ 子服断开: [%s] (%s) | 剩余节点: %d", + info["server_name"], chan, len(clients) + ) + + +async def main(): + """启动 WebSocket 服务器与 Web 监控页面。""" + host = "0.0.0.0" # skipcq: BAN-B104 + ws_port = 8765 + web_port = 8766 + + logging.info("🚀 互通中转核心鉴权网关已启动") + + await start_web_server(host, web_port) + + logging.info("🕹️ 跨服 WS 网关正在监听 ws://%s:%d", host, ws_port) + async with websockets.serve(handle_client, host, ws_port): + await asyncio.Future() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git "a/\346\234\215\346\234\215\344\272\222\351\200\232\350\201\212\345\244\251/datas.json" "b/\346\234\215\346\234\215\344\272\222\351\200\232\350\201\212\345\244\251/datas.json" new file mode 100644 index 00000000..9e8c0a33 --- /dev/null +++ "b/\346\234\215\346\234\215\344\272\222\351\200\232\350\201\212\345\244\251/datas.json" @@ -0,0 +1,10 @@ +{ + "plugin-id": "server-interconnection", + "author": "哈茶块", + "version": "2.3.2", + "description": "实现服服互通!可以让多个服务器内的聊天消息互通并且拥有webUI", + "plugin-type": "classic", + "pre-plugins": { + "pip模块支持": "0.0.6" + } +} diff --git "a/\346\234\215\346\234\215\344\272\222\351\200\232\350\201\212\345\244\251/readme.txt" "b/\346\234\215\346\234\215\344\272\222\351\200\232\350\201\212\345\244\251/readme.txt" new file mode 100644 index 00000000..cfc8dd95 --- /dev/null +++ "b/\346\234\215\346\234\215\344\272\222\351\200\232\350\201\212\345\244\251/readme.txt" @@ -0,0 +1 @@ +这是一个服服互通聊天的插件您可以选择部署自己的中转服务器下载 core_server.py 文件并使用 python3 core_server.py 运行随后在配置文件内修改服务器地址即可如果您听不懂以上内容您可以不修改中转服务器地址使用默认的 ws://core.aurorabot.top \ No newline at end of file