nonebot-l4d2-bot 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nonebot_l4d2_bot-1.0.0/PKG-INFO +14 -0
- nonebot_l4d2_bot-1.0.0/nonebot_l4d2_bot.egg-info/PKG-INFO +14 -0
- nonebot_l4d2_bot-1.0.0/nonebot_l4d2_bot.egg-info/SOURCES.txt +17 -0
- nonebot_l4d2_bot-1.0.0/nonebot_l4d2_bot.egg-info/dependency_links.txt +1 -0
- nonebot_l4d2_bot-1.0.0/nonebot_l4d2_bot.egg-info/requires.txt +6 -0
- nonebot_l4d2_bot-1.0.0/nonebot_l4d2_bot.egg-info/top_level.txt +1 -0
- nonebot_l4d2_bot-1.0.0/plugins/nonebot_l4d2_bot/__init__.py +26 -0
- nonebot_l4d2_bot-1.0.0/plugins/nonebot_l4d2_bot/commands.py +70 -0
- nonebot_l4d2_bot-1.0.0/plugins/nonebot_l4d2_bot/config.py +36 -0
- nonebot_l4d2_bot-1.0.0/plugins/nonebot_l4d2_bot/connection.py +74 -0
- nonebot_l4d2_bot-1.0.0/plugins/nonebot_l4d2_bot/forwarder.py +387 -0
- nonebot_l4d2_bot-1.0.0/plugins/nonebot_l4d2_bot/http_server.py +258 -0
- nonebot_l4d2_bot-1.0.0/plugins/nonebot_l4d2_bot/log.py +4 -0
- nonebot_l4d2_bot-1.0.0/plugins/nonebot_l4d2_bot/protocol.py +158 -0
- nonebot_l4d2_bot-1.0.0/plugins/nonebot_l4d2_bot/render.py +464 -0
- nonebot_l4d2_bot-1.0.0/plugins/nonebot_l4d2_bot/server_query.py +362 -0
- nonebot_l4d2_bot-1.0.0/plugins/nonebot_l4d2_bot/ws_server.py +212 -0
- nonebot_l4d2_bot-1.0.0/pyproject.toml +31 -0
- nonebot_l4d2_bot-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nonebot-l4d2-bot
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: NoneBot2 <-> Left 4 Dead 2 SourceMod Bridge
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/yourname/nonebot-l4d2-bot
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: nonebot2[fastapi]>=2.3.0
|
|
10
|
+
Requires-Dist: nonebot-adapter-onebot>=2.4.0
|
|
11
|
+
Requires-Dist: pydantic>=2.0
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Requires-Dist: aiofiles>=24.1
|
|
14
|
+
Requires-Dist: Pillow>=10.0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nonebot-l4d2-bot
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: NoneBot2 <-> Left 4 Dead 2 SourceMod Bridge
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/yourname/nonebot-l4d2-bot
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: nonebot2[fastapi]>=2.3.0
|
|
10
|
+
Requires-Dist: nonebot-adapter-onebot>=2.4.0
|
|
11
|
+
Requires-Dist: pydantic>=2.0
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Requires-Dist: aiofiles>=24.1
|
|
14
|
+
Requires-Dist: Pillow>=10.0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
nonebot_l4d2_bot.egg-info/PKG-INFO
|
|
3
|
+
nonebot_l4d2_bot.egg-info/SOURCES.txt
|
|
4
|
+
nonebot_l4d2_bot.egg-info/dependency_links.txt
|
|
5
|
+
nonebot_l4d2_bot.egg-info/requires.txt
|
|
6
|
+
nonebot_l4d2_bot.egg-info/top_level.txt
|
|
7
|
+
plugins/nonebot_l4d2_bot/__init__.py
|
|
8
|
+
plugins/nonebot_l4d2_bot/commands.py
|
|
9
|
+
plugins/nonebot_l4d2_bot/config.py
|
|
10
|
+
plugins/nonebot_l4d2_bot/connection.py
|
|
11
|
+
plugins/nonebot_l4d2_bot/forwarder.py
|
|
12
|
+
plugins/nonebot_l4d2_bot/http_server.py
|
|
13
|
+
plugins/nonebot_l4d2_bot/log.py
|
|
14
|
+
plugins/nonebot_l4d2_bot/protocol.py
|
|
15
|
+
plugins/nonebot_l4d2_bot/render.py
|
|
16
|
+
plugins/nonebot_l4d2_bot/server_query.py
|
|
17
|
+
plugins/nonebot_l4d2_bot/ws_server.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nonebot_l4d2_bot
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from nonebot import get_driver, get_plugin_config
|
|
2
|
+
from nonebot.plugin import PluginMetadata
|
|
3
|
+
|
|
4
|
+
from .config import BridgeConfig
|
|
5
|
+
from .ws_server import setup_ws_server
|
|
6
|
+
from .http_server import setup_http_server
|
|
7
|
+
from .forwarder import setup_forwarder
|
|
8
|
+
from .commands import setup_commands
|
|
9
|
+
|
|
10
|
+
__plugin_meta__ = PluginMetadata(
|
|
11
|
+
name="L4D2 Bridge",
|
|
12
|
+
description="NoneBot2 与 Left 4 Dead 2 SourceMod 双向消息/文件桥接",
|
|
13
|
+
usage="自动运行,无需手动命令",
|
|
14
|
+
config=BridgeConfig,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
driver = get_driver()
|
|
18
|
+
config = get_plugin_config(BridgeConfig)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@driver.on_startup
|
|
22
|
+
async def _startup():
|
|
23
|
+
setup_ws_server(config)
|
|
24
|
+
setup_http_server(config)
|
|
25
|
+
setup_forwarder(config)
|
|
26
|
+
setup_commands(config)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
|
|
3
|
+
from nonebot import on_startswith, Bot
|
|
4
|
+
from nonebot.adapters.onebot.v11 import GroupMessageEvent, MessageSegment
|
|
5
|
+
from nonebot.rule import Rule
|
|
6
|
+
|
|
7
|
+
from .config import BridgeConfig
|
|
8
|
+
from .connection import conn_mgr
|
|
9
|
+
from .server_query import query_server_auto, parse_server_addr
|
|
10
|
+
from .render import render_server_info, render_server_offline, render_bridge_status
|
|
11
|
+
|
|
12
|
+
_config: BridgeConfig = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _is_bridge_group() -> Rule:
|
|
16
|
+
async def checker(event: GroupMessageEvent) -> bool:
|
|
17
|
+
return str(event.group_id) in _config.l4d2_bridge_qq_groups
|
|
18
|
+
return Rule(checker)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def setup_commands(config: BridgeConfig):
|
|
22
|
+
global _config
|
|
23
|
+
_config = config
|
|
24
|
+
|
|
25
|
+
grp = _is_bridge_group()
|
|
26
|
+
|
|
27
|
+
on_startswith("状态", rule=grp, priority=10).handle()(
|
|
28
|
+
_handle_status_cmd)
|
|
29
|
+
|
|
30
|
+
on_startswith("connect", rule=grp, priority=10).handle()(
|
|
31
|
+
_handle_query_cmd)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _handle_status_cmd(bot: Bot, event: GroupMessageEvent):
|
|
35
|
+
conns = conn_mgr.all_connections
|
|
36
|
+
if not conns:
|
|
37
|
+
await bot.send(event, "当前无服务器连接")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
data = [
|
|
41
|
+
(sid, int(conn.alive_seconds / 60))
|
|
42
|
+
for sid, conn in conns.items()
|
|
43
|
+
]
|
|
44
|
+
img = render_bridge_status(data)
|
|
45
|
+
b64 = base64.b64encode(img).decode()
|
|
46
|
+
await bot.send(event, MessageSegment.image(f"base64://{b64}"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _handle_query_cmd(bot: Bot, event: GroupMessageEvent):
|
|
50
|
+
raw = str(event.get_message()).strip()
|
|
51
|
+
if raw.startswith("connect"):
|
|
52
|
+
raw = raw[7:].strip()
|
|
53
|
+
|
|
54
|
+
if not raw:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
_, host, port = parse_server_addr(raw)
|
|
58
|
+
info = await query_server_auto(host, port)
|
|
59
|
+
|
|
60
|
+
if info is None:
|
|
61
|
+
display_port = port if port > 0 else 27015
|
|
62
|
+
img = render_server_offline(host, display_port)
|
|
63
|
+
else:
|
|
64
|
+
img = render_server_info(
|
|
65
|
+
info.server_name, info.map_display,
|
|
66
|
+
info.real_players, info.max_players,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
b64 = base64.b64encode(img).decode()
|
|
70
|
+
await bot.send(event, MessageSegment.image(f"base64://{b64}"))
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BridgeConfig(BaseModel):
|
|
8
|
+
l4d2_bridge_token: str = "change_me_to_a_secure_token"
|
|
9
|
+
l4d2_bridge_ws_path: str = "/ws/l4d2"
|
|
10
|
+
l4d2_bridge_file_path: str = "/v1/files"
|
|
11
|
+
l4d2_bridge_heartbeat_interval: int = 15
|
|
12
|
+
l4d2_bridge_upload_max_mb: int = 20
|
|
13
|
+
l4d2_bridge_download_dir: str = "data/l4d2_bridge/inbox"
|
|
14
|
+
l4d2_bridge_upload_dir: str = "data/l4d2_bridge/outbox"
|
|
15
|
+
l4d2_bridge_allowed_extensions: List[str] = Field(
|
|
16
|
+
default_factory=lambda: ["vpk"]
|
|
17
|
+
)
|
|
18
|
+
l4d2_bridge_game_servers: List[str] = Field(
|
|
19
|
+
default_factory=list
|
|
20
|
+
)
|
|
21
|
+
l4d2_bridge_qq_groups: List[str] = Field(default_factory=lambda: [])
|
|
22
|
+
l4d2_bridge_hmac_window_sec: int = 30
|
|
23
|
+
l4d2_bridge_msg_dedup_window_sec: int = 600
|
|
24
|
+
l4d2_bridge_reconnect_max_retries: int = -1
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def download_path(self) -> Path:
|
|
28
|
+
p = Path(self.l4d2_bridge_download_dir)
|
|
29
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
return p
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def upload_path(self) -> Path:
|
|
34
|
+
p = Path(self.l4d2_bridge_upload_dir)
|
|
35
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
return p
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
from nonebot.log import logger
|
|
6
|
+
|
|
7
|
+
from .log import _clog, _TAG
|
|
8
|
+
from .protocol import BridgePacket
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GameServerConn:
|
|
12
|
+
|
|
13
|
+
def __init__(self, server_id: str, ws):
|
|
14
|
+
self.server_id = server_id
|
|
15
|
+
self.ws = ws
|
|
16
|
+
self.connected_at: float = time.time()
|
|
17
|
+
self.last_ping: float = time.time()
|
|
18
|
+
self.authenticated: bool = False
|
|
19
|
+
self._send_lock = asyncio.Lock()
|
|
20
|
+
|
|
21
|
+
async def send_packet(self, pkt: BridgePacket):
|
|
22
|
+
async with self._send_lock:
|
|
23
|
+
try:
|
|
24
|
+
await self.ws.send_text(pkt.model_dump_json())
|
|
25
|
+
except Exception as e:
|
|
26
|
+
_clog.error(f"{_TAG} 发送数据包到 {self.server_id} 失败: {e}")
|
|
27
|
+
raise
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def alive_seconds(self) -> float:
|
|
31
|
+
return time.time() - self.connected_at
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConnectionManager:
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self._conns: Dict[str, GameServerConn] = {}
|
|
38
|
+
|
|
39
|
+
def add(self, server_id: str, ws) -> GameServerConn:
|
|
40
|
+
if server_id in self._conns:
|
|
41
|
+
_clog.warning(f"{_TAG} 覆盖已有连接: {server_id}")
|
|
42
|
+
conn = GameServerConn(server_id, ws)
|
|
43
|
+
self._conns[server_id] = conn
|
|
44
|
+
_clog.info(f"{_TAG} 服务器已连接: {server_id}")
|
|
45
|
+
return conn
|
|
46
|
+
|
|
47
|
+
def remove(self, server_id: str):
|
|
48
|
+
if server_id in self._conns:
|
|
49
|
+
del self._conns[server_id]
|
|
50
|
+
_clog.info(f"{_TAG} 服务器已断开: {server_id}")
|
|
51
|
+
|
|
52
|
+
def get(self, server_id: str) -> Optional[GameServerConn]:
|
|
53
|
+
return self._conns.get(server_id)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def all_connections(self) -> Dict[str, GameServerConn]:
|
|
57
|
+
return dict(self._conns)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def count(self) -> int:
|
|
61
|
+
return len(self._conns)
|
|
62
|
+
|
|
63
|
+
async def broadcast(self, pkt: BridgePacket, exclude: str = ""):
|
|
64
|
+
for sid, conn in list(self._conns.items()):
|
|
65
|
+
if sid == exclude or not conn.authenticated:
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
await conn.send_packet(pkt)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
_clog.error(f"{_TAG} 广播到 {sid} 失败: {e}")
|
|
71
|
+
self.remove(sid)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
conn_mgr = ConnectionManager()
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path, PurePosixPath
|
|
4
|
+
from urllib.parse import urlparse, unquote
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import nonebot
|
|
9
|
+
from nonebot import on_message, Bot
|
|
10
|
+
from nonebot.adapters.onebot.v11 import (
|
|
11
|
+
GroupMessageEvent,
|
|
12
|
+
Message,
|
|
13
|
+
)
|
|
14
|
+
from nonebot.rule import Rule
|
|
15
|
+
|
|
16
|
+
from .config import BridgeConfig
|
|
17
|
+
from .log import _clog, _TAG
|
|
18
|
+
from .connection import conn_mgr
|
|
19
|
+
from .protocol import make_file_in_notice
|
|
20
|
+
from .ws_server import on_file_out
|
|
21
|
+
from .http_server import save_file_for_game, get_file_meta
|
|
22
|
+
|
|
23
|
+
_URL_RE = re.compile(r'https?://[^\s"<>\]\)}{,]+', re.IGNORECASE)
|
|
24
|
+
|
|
25
|
+
_FLASH_RE = re.compile(
|
|
26
|
+
r'(?:'
|
|
27
|
+
r'\[flashtransfer:fileSetId=([a-f0-9\-]+)\]'
|
|
28
|
+
r'|'
|
|
29
|
+
r'\[CQ:flashtransfer,fileSetId=([a-f0-9\-]+)\]'
|
|
30
|
+
r')',
|
|
31
|
+
re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_CD_NAME_RE = re.compile(r'filename[*]?=["\']?([^"\'\;]+)')
|
|
35
|
+
|
|
36
|
+
_DEFAULT_HEADERS = {
|
|
37
|
+
"User-Agent": (
|
|
38
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
39
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
40
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_QQ_HEADERS = {
|
|
45
|
+
**_DEFAULT_HEADERS,
|
|
46
|
+
"User-Agent": _DEFAULT_HEADERS["User-Agent"] + " QQ/9.9.15",
|
|
47
|
+
"Referer": "https://qq.com",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_config: BridgeConfig = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_bridge_group() -> Rule:
|
|
54
|
+
async def checker(event: GroupMessageEvent) -> bool:
|
|
55
|
+
return str(event.group_id) in _config.l4d2_bridge_qq_groups
|
|
56
|
+
return Rule(checker)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _starts_with_download() -> Rule:
|
|
60
|
+
async def checker(event: GroupMessageEvent) -> bool:
|
|
61
|
+
text = event.get_plaintext().strip()
|
|
62
|
+
if not text.startswith("下载"):
|
|
63
|
+
return False
|
|
64
|
+
return bool(_URL_RE.search(text))
|
|
65
|
+
return Rule(checker)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def setup_forwarder(config: BridgeConfig):
|
|
69
|
+
global _config
|
|
70
|
+
_config = config
|
|
71
|
+
|
|
72
|
+
_register_game_to_bot_handlers()
|
|
73
|
+
|
|
74
|
+
grp = _is_bridge_group()
|
|
75
|
+
|
|
76
|
+
on_message(rule=grp, priority=85, block=False).handle()(
|
|
77
|
+
_handle_flash_message)
|
|
78
|
+
|
|
79
|
+
on_message(rule=grp, priority=90, block=False).handle()(
|
|
80
|
+
_handle_card_message)
|
|
81
|
+
|
|
82
|
+
on_message(rule=grp & _starts_with_download(), priority=10).handle()(
|
|
83
|
+
_handle_download_cmd)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _filename_from_url(url: str, fallback: str = "download") -> str:
|
|
87
|
+
try:
|
|
88
|
+
parsed = urlparse(url)
|
|
89
|
+
path = unquote(parsed.path)
|
|
90
|
+
name = PurePosixPath(path).name
|
|
91
|
+
if name:
|
|
92
|
+
return name
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
return fallback
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cd_filename(headers: httpx.Headers) -> Optional[str]:
|
|
99
|
+
cd = headers.get("content-disposition", "")
|
|
100
|
+
if cd:
|
|
101
|
+
m = _CD_NAME_RE.search(cd)
|
|
102
|
+
if m:
|
|
103
|
+
return unquote(m.group(1).strip()) or None
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_vpk_url(url: str) -> bool:
|
|
108
|
+
try:
|
|
109
|
+
return unquote(urlparse(url).path).lower().endswith(".vpk")
|
|
110
|
+
except Exception:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _sender_name(event: GroupMessageEvent) -> str:
|
|
115
|
+
return (
|
|
116
|
+
event.sender.card or event.sender.nickname
|
|
117
|
+
or str(event.user_id)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _stream_download(
|
|
122
|
+
url: str,
|
|
123
|
+
headers: Optional[dict] = None,
|
|
124
|
+
verify: bool = True,
|
|
125
|
+
) -> Tuple[bytes, float]:
|
|
126
|
+
hdrs = headers or _DEFAULT_HEADERS
|
|
127
|
+
t0 = time.monotonic()
|
|
128
|
+
async with httpx.AsyncClient(
|
|
129
|
+
timeout=120, follow_redirects=True, verify=verify,
|
|
130
|
+
) as client:
|
|
131
|
+
async with client.stream("GET", url, headers=hdrs) as resp:
|
|
132
|
+
resp.raise_for_status()
|
|
133
|
+
cd_name = _cd_filename(resp.headers)
|
|
134
|
+
chunks = []
|
|
135
|
+
async for chunk in resp.aiter_bytes(65536):
|
|
136
|
+
chunks.append(chunk)
|
|
137
|
+
content = b"".join(chunks)
|
|
138
|
+
dl_sec = time.monotonic() - t0
|
|
139
|
+
return content, dl_sec, cd_name
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _save_and_push(
|
|
143
|
+
bot: Bot, event: GroupMessageEvent,
|
|
144
|
+
content: bytes, file_name: str,
|
|
145
|
+
channel: str, sender: str, dl_sec: float,
|
|
146
|
+
label: str):
|
|
147
|
+
meta = await save_file_for_game(content, file_name)
|
|
148
|
+
if not meta:
|
|
149
|
+
size_mb = len(content) / (1024 * 1024)
|
|
150
|
+
max_mb = _config.l4d2_bridge_upload_max_mb
|
|
151
|
+
if size_mb > max_mb:
|
|
152
|
+
await bot.send(event,
|
|
153
|
+
f"{label} 超出大小限制: {size_mb:.1f}MB > {max_mb}MB")
|
|
154
|
+
else:
|
|
155
|
+
await bot.send(event, f"{label} 保存失败: {file_name}")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
pkt = make_file_in_notice(
|
|
159
|
+
channel=channel,
|
|
160
|
+
file_id=meta["file_id"],
|
|
161
|
+
file_name=meta["file_name"],
|
|
162
|
+
size=meta["size"],
|
|
163
|
+
sha256=meta["sha256"],
|
|
164
|
+
secret=_config.l4d2_bridge_token,
|
|
165
|
+
)
|
|
166
|
+
await conn_mgr.broadcast(pkt)
|
|
167
|
+
|
|
168
|
+
size_mb = meta["size"] / (1024 * 1024)
|
|
169
|
+
speed = size_mb / dl_sec if dl_sec > 0 else 0
|
|
170
|
+
await bot.send(event,
|
|
171
|
+
f"下载进度: 100%\n"
|
|
172
|
+
f"{label} 已推送至 {conn_mgr.count} 台服务器\n"
|
|
173
|
+
f"文件: {meta['file_name']}\n"
|
|
174
|
+
f"大小: {size_mb:.2f} MB | 速率: {speed:.2f} MB/s")
|
|
175
|
+
_clog.info(
|
|
176
|
+
f"{_TAG} {label}→Game: {file_name} "
|
|
177
|
+
f"({meta['size']} bytes) from {sender}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _handle_card_message(bot: Bot, event: GroupMessageEvent):
|
|
181
|
+
urls = set()
|
|
182
|
+
for seg in event.message:
|
|
183
|
+
if seg.type in ("json", "xml"):
|
|
184
|
+
urls.update(_URL_RE.findall(seg.data.get("data", "")))
|
|
185
|
+
elif seg.type == "share":
|
|
186
|
+
url = seg.data.get("url", "")
|
|
187
|
+
if url:
|
|
188
|
+
urls.add(url)
|
|
189
|
+
|
|
190
|
+
vpk_urls = [u for u in urls if _is_vpk_url(u)]
|
|
191
|
+
if not vpk_urls:
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
sender = _sender_name(event)
|
|
195
|
+
channel = f"qq_group:{event.group_id}"
|
|
196
|
+
|
|
197
|
+
for url in vpk_urls:
|
|
198
|
+
file_name = _filename_from_url(url, "download.vpk")
|
|
199
|
+
_clog.info(f"{_TAG} VPK下载开始: {file_name} from {sender}")
|
|
200
|
+
await bot.send(event, f"检测到 VPK 链接,开始下载: {file_name}")
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
content, dl_sec, cd_name = await _stream_download(url)
|
|
204
|
+
if cd_name and cd_name.lower().endswith(".vpk"):
|
|
205
|
+
file_name = cd_name
|
|
206
|
+
except Exception as e:
|
|
207
|
+
_clog.error(f"{_TAG} 下载 VPK 失败: {e}")
|
|
208
|
+
await bot.send(event, f"VPK 下载失败: {e}")
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
await _save_and_push(
|
|
212
|
+
bot, event, content, file_name,
|
|
213
|
+
channel, sender, dl_sec, "VPK 文件")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def _handle_flash_message(bot: Bot, event: GroupMessageEvent):
|
|
217
|
+
fileset_id = ""
|
|
218
|
+
for seg in event.message:
|
|
219
|
+
if seg.type == "flashtransfer":
|
|
220
|
+
fileset_id = seg.data.get("fileSetId", "")
|
|
221
|
+
break
|
|
222
|
+
if not fileset_id:
|
|
223
|
+
raw = str(event.get_message())
|
|
224
|
+
m = _FLASH_RE.search(raw)
|
|
225
|
+
if not m:
|
|
226
|
+
return
|
|
227
|
+
fileset_id = m.group(1) or m.group(2)
|
|
228
|
+
|
|
229
|
+
sender = _sender_name(event)
|
|
230
|
+
channel = f"qq_group:{event.group_id}"
|
|
231
|
+
_clog.info(f"{_TAG} 检测到闪传: fileSetId={fileset_id}")
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
resp = await bot.call_api(
|
|
235
|
+
"get_flash_file_list", fileset_id=fileset_id)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
_clog.warning(f"{_TAG} 获取闪传文件列表失败: {e}")
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
files = []
|
|
241
|
+
if isinstance(resp, dict):
|
|
242
|
+
for fl in resp.get("fileLists", []):
|
|
243
|
+
for item in fl.get("fileList", []):
|
|
244
|
+
name = item.get("name", "")
|
|
245
|
+
size = int(item.get("fileSize", 0))
|
|
246
|
+
if name:
|
|
247
|
+
files.append({"file_name": name, "size": size})
|
|
248
|
+
elif isinstance(resp, list):
|
|
249
|
+
files = [
|
|
250
|
+
{"file_name": f.get("file_name", ""),
|
|
251
|
+
"size": f.get("size", 0)} for f in resp
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
vpk_files = [
|
|
255
|
+
f for f in files
|
|
256
|
+
if f["file_name"].lower().endswith(".vpk")
|
|
257
|
+
]
|
|
258
|
+
if not vpk_files:
|
|
259
|
+
if files:
|
|
260
|
+
names = [f["file_name"] for f in files]
|
|
261
|
+
_clog.info(f"{_TAG} 闪传无VPK文件: {names}")
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
for f in vpk_files:
|
|
265
|
+
file_name = f.get("file_name", "download.vpk")
|
|
266
|
+
await bot.send(event, f"检测到 VPK 文件,开始下载: {file_name}")
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
url_resp = await bot.call_api(
|
|
270
|
+
"get_flash_file_url",
|
|
271
|
+
fileset_id=fileset_id,
|
|
272
|
+
file_name=file_name)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
_clog.error(f"{_TAG} 获取闪传文件URL失败: {e}")
|
|
275
|
+
await bot.send(event, f"闪传文件URL获取失败: {e}")
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
file_url = ""
|
|
279
|
+
if isinstance(url_resp, str):
|
|
280
|
+
file_url = url_resp
|
|
281
|
+
elif isinstance(url_resp, dict):
|
|
282
|
+
file_url = (
|
|
283
|
+
url_resp.get("transferUrl", "")
|
|
284
|
+
or url_resp.get("url", ""))
|
|
285
|
+
if not file_url:
|
|
286
|
+
_clog.warning(
|
|
287
|
+
f"{_TAG} 闪传文件URL为空: {file_name}, resp={url_resp}")
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
content, dl_sec, _ = await _stream_download(
|
|
292
|
+
file_url, headers=_QQ_HEADERS, verify=False)
|
|
293
|
+
except Exception as e:
|
|
294
|
+
_clog.error(
|
|
295
|
+
f"{_TAG} 下载闪传VPK失败: {type(e).__name__}: {e}")
|
|
296
|
+
await bot.send(
|
|
297
|
+
event, f"VPK 下载失败: {type(e).__name__}: {e}")
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
await _save_and_push(
|
|
301
|
+
bot, event, content, file_name,
|
|
302
|
+
channel, sender, dl_sec, "VPK 文件")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
async def _handle_download_cmd(bot: Bot, event: GroupMessageEvent):
|
|
306
|
+
raw = event.get_plaintext().strip().lstrip("下载").strip()
|
|
307
|
+
|
|
308
|
+
urls = _URL_RE.findall(raw)
|
|
309
|
+
if not urls:
|
|
310
|
+
await bot.send(
|
|
311
|
+
event, "请提供下载链接,例如:下载 https://example.com/file.vpk")
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
sender = _sender_name(event)
|
|
315
|
+
channel = f"qq_group:{event.group_id}"
|
|
316
|
+
|
|
317
|
+
for url in urls:
|
|
318
|
+
file_name = _filename_from_url(url)
|
|
319
|
+
_clog.info(f"{_TAG} 直链下载: {file_name} from {sender}")
|
|
320
|
+
await bot.send(event, f"开始解析直链: {file_name}")
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
content, dl_sec, cd_name = await _stream_download(url)
|
|
324
|
+
if cd_name:
|
|
325
|
+
file_name = cd_name
|
|
326
|
+
except Exception as e:
|
|
327
|
+
_clog.error(f"{_TAG} 直链下载失败: {e}")
|
|
328
|
+
await bot.send(event, f"下载失败:{e}")
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
await _save_and_push(
|
|
332
|
+
bot, event, content, file_name,
|
|
333
|
+
channel, sender, dl_sec, "文件")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _register_game_to_bot_handlers():
|
|
337
|
+
|
|
338
|
+
@on_file_out
|
|
339
|
+
async def _game_file_to_qq(
|
|
340
|
+
server_id: str, channel: str, file_id: str,
|
|
341
|
+
file_name: str, size: int, sha256: str):
|
|
342
|
+
meta = get_file_meta(file_id)
|
|
343
|
+
if meta and Path(meta["path"]).exists():
|
|
344
|
+
await _send_file_to_qq_groups(
|
|
345
|
+
str(Path(meta["path"]).resolve()), file_name)
|
|
346
|
+
size_kb = size / 1024
|
|
347
|
+
await _send_to_qq_groups(
|
|
348
|
+
f"[{server_id}] 上传文件: {file_name}\n"
|
|
349
|
+
f"大小: {size_kb:.1f} KB")
|
|
350
|
+
else:
|
|
351
|
+
_clog.warning(f"{_TAG} 文件未找到: {file_id}")
|
|
352
|
+
await _send_to_qq_groups(
|
|
353
|
+
f"[{server_id}] 上传文件: {file_name} (文件不可用)")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
async def _send_to_qq_groups(text: str):
|
|
357
|
+
try:
|
|
358
|
+
bot = nonebot.get_bot()
|
|
359
|
+
except ValueError:
|
|
360
|
+
_clog.warning(f"{_TAG} 没有可用的 Bot 实例")
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
for gid in _config.l4d2_bridge_qq_groups:
|
|
364
|
+
try:
|
|
365
|
+
await bot.send_group_msg(
|
|
366
|
+
group_id=int(gid), message=Message(text))
|
|
367
|
+
except Exception as e:
|
|
368
|
+
_clog.error(f"{_TAG} 发送到群 {gid} 失败: {e}")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
async def _send_file_to_qq_groups(file_path: str, file_name: str):
|
|
372
|
+
try:
|
|
373
|
+
bot = nonebot.get_bot()
|
|
374
|
+
except ValueError:
|
|
375
|
+
_clog.warning(f"{_TAG} 没有可用的 Bot 实例")
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
for gid in _config.l4d2_bridge_qq_groups:
|
|
379
|
+
try:
|
|
380
|
+
await bot.call_api(
|
|
381
|
+
"upload_group_file",
|
|
382
|
+
group_id=int(gid),
|
|
383
|
+
file=file_path,
|
|
384
|
+
name=file_name,
|
|
385
|
+
)
|
|
386
|
+
except Exception as e:
|
|
387
|
+
_clog.error(f"{_TAG} 发送文件到群 {gid} 失败: {e}")
|