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.
@@ -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,6 @@
1
+ nonebot2[fastapi]>=2.3.0
2
+ nonebot-adapter-onebot>=2.4.0
3
+ pydantic>=2.0
4
+ httpx>=0.27
5
+ aiofiles>=24.1
6
+ Pillow>=10.0
@@ -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}")