nonebot-plugin-proxy-probe 0.2.2__py3-none-any.whl

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,26 @@
1
+ """NoneBot 代理扫描与缓存插件。"""
2
+
3
+ from nonebot import require
4
+ from nonebot.plugin import PluginMetadata
5
+
6
+ require("nonebot_plugin_localstore")
7
+
8
+ from .config import PluginConfig # noqa: E402
9
+
10
+ __plugin_meta__ = PluginMetadata(
11
+ name="代理扫描",
12
+ description="扫描并缓存 Clash HTTP 代理、出口 IP 与属地",
13
+ usage=(
14
+ "/proxy 显示缓存结果\n"
15
+ "/proxy -h 或 --help 显示帮助\n"
16
+ "/proxy -i <IPv4> 或 --ip <IPv4> 设置目标参考 IP\n"
17
+ "/proxy -p 或 --probe 重新扫描\n"
18
+ "/proxy -r 或 --refresh 刷新缓存\n"
19
+ "/proxy -s 或 --stop 停止后台任务"
20
+ ),
21
+ type="application",
22
+ supported_adapters={"~onebot.v11"},
23
+ config=PluginConfig,
24
+ )
25
+
26
+ from . import commands as commands # noqa: E402,F401
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import json
5
+ import os
6
+ import threading
7
+ from pathlib import Path
8
+
9
+ from nonebot import require
10
+
11
+ require("nonebot_plugin_localstore")
12
+
13
+ import nonebot_plugin_localstore as localstore # noqa: E402
14
+
15
+ from .models import CacheState # noqa: E402
16
+
17
+
18
+ DATA_DIR: Path = localstore.get_plugin_data_dir()
19
+ CACHE_PATH = DATA_DIR / "proxy_cache.json"
20
+ SETTINGS_PATH = DATA_DIR / "settings.json"
21
+ _CACHE_LOCK = threading.RLock()
22
+
23
+
24
+ def _write_json(path: Path, data: dict[str, object]) -> None:
25
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
26
+ payload = json.dumps(data, ensure_ascii=False, indent=2)
27
+ temporary = path.with_suffix(path.suffix + ".tmp")
28
+ temporary.write_text(payload, encoding="utf-8")
29
+ os.replace(temporary, path)
30
+
31
+
32
+ def load_cache() -> CacheState:
33
+ with _CACHE_LOCK:
34
+ try:
35
+ raw = json.loads(CACHE_PATH.read_text(encoding="utf-8"))
36
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
37
+ return CacheState()
38
+ if not isinstance(raw, dict):
39
+ return CacheState()
40
+ try:
41
+ state = CacheState.from_dict(raw)
42
+ except (TypeError, ValueError):
43
+ return CacheState()
44
+
45
+ # Bot 重启后不存在可恢复的工作线程,不能继续显示“运行中”。
46
+ if state.running:
47
+ state.running = False
48
+ state.task_status = "任务因 Bot 重启而中止"
49
+ return state
50
+
51
+
52
+ def save_cache(state: CacheState) -> None:
53
+ with _CACHE_LOCK:
54
+ _write_json(CACHE_PATH, state.to_dict())
55
+
56
+
57
+ def load_target_ip() -> str:
58
+ """读取用户通过命令持久化设置的目标参考 IPv4。"""
59
+ with _CACHE_LOCK:
60
+ try:
61
+ raw = json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
62
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
63
+ return ""
64
+ if not isinstance(raw, dict):
65
+ return ""
66
+ value = str(raw.get("target_ip") or "").strip()
67
+ try:
68
+ return str(ipaddress.IPv4Address(value)) if value else ""
69
+ except ValueError:
70
+ return ""
71
+
72
+
73
+ def save_target_ip(target_ip: str) -> None:
74
+ normalized = str(ipaddress.IPv4Address(target_ip))
75
+ with _CACHE_LOCK:
76
+ _write_json(
77
+ SETTINGS_PATH,
78
+ {
79
+ "version": 1,
80
+ "target_ip": normalized,
81
+ },
82
+ )
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+
5
+ from nonebot import on_command
6
+ from nonebot.adapters.onebot.v11 import (
7
+ Bot,
8
+ GroupMessageEvent,
9
+ Message,
10
+ MessageEvent,
11
+ )
12
+ from nonebot.log import logger
13
+ from nonebot.params import CommandArg
14
+
15
+ from .manager import manager
16
+
17
+
18
+ USAGE = (
19
+ "用法:\n"
20
+ "/proxy 显示缓存结果\n"
21
+ "/proxy -h 或 --help 显示命令帮助\n"
22
+ "/proxy -i <IPv4> 或 --ip <IPv4> 设置目标参考 IP\n"
23
+ "/proxy -p 或 --probe 重新扫描\n"
24
+ "/proxy -r 或 --refresh 刷新缓存\n"
25
+ "/proxy -s 或 --stop 停止后台任务"
26
+ )
27
+
28
+
29
+ def parse_command(argument: str) -> tuple[str, str] | None:
30
+ normalized = " ".join(argument.strip().split())
31
+ actions = {
32
+ "": ("show", ""),
33
+ "-h": ("help", ""),
34
+ "--help": ("help", ""),
35
+ "-p": ("probe", ""),
36
+ "--probe": ("probe", ""),
37
+ "-r": ("refresh", ""),
38
+ "--refresh": ("refresh", ""),
39
+ "-s": ("stop", ""),
40
+ "--stop": ("stop", ""),
41
+ }
42
+ exact = actions.get(normalized)
43
+ if exact is not None:
44
+ return exact
45
+
46
+ if normalized.startswith("--ip="):
47
+ return "ip", normalized.partition("=")[2].strip()
48
+ fields = normalized.split(" ", 1)
49
+ if fields[0] in {"-i", "--ip"}:
50
+ return "ip", fields[1].strip() if len(fields) == 2 else ""
51
+ return None
52
+
53
+
54
+ def parse_action(argument: str) -> str | None:
55
+ """保留简单动作解析接口,供旧调用方兼容。"""
56
+ parsed = parse_command(argument)
57
+ return parsed[0] if parsed is not None else None
58
+
59
+
60
+ async def set_emoji(
61
+ bot: Bot,
62
+ event: MessageEvent,
63
+ emoji_id: str,
64
+ ) -> None:
65
+ if not isinstance(event, GroupMessageEvent):
66
+ return
67
+ try:
68
+ await bot.call_api(
69
+ "set_msg_emoji_like",
70
+ group_id=event.group_id,
71
+ message_id=event.message_id,
72
+ emoji_id=emoji_id,
73
+ set=True,
74
+ )
75
+ except Exception as exc:
76
+ logger.warning(f"设置消息表情失败: {exc}")
77
+
78
+
79
+ proxy_command = on_command("proxy", priority=5, block=True)
80
+
81
+
82
+ @proxy_command.handle()
83
+ async def handle_proxy(
84
+ bot: Bot,
85
+ event: MessageEvent,
86
+ args: Message = CommandArg(),
87
+ ) -> None:
88
+ parsed = parse_command(args.extract_plain_text())
89
+ if parsed is None:
90
+ await proxy_command.finish(USAGE)
91
+ action, value = parsed
92
+
93
+ if action == "show":
94
+ await proxy_command.finish(manager.get_image_segment())
95
+
96
+ if action == "help":
97
+ await proxy_command.finish(USAGE)
98
+
99
+ if action == "ip":
100
+ if not value:
101
+ await proxy_command.finish(
102
+ "请提供目标参考 IPv4,例如:"
103
+ "/proxy -i 218.194.50.3"
104
+ )
105
+ try:
106
+ target_ip = str(ipaddress.IPv4Address(value))
107
+ except ValueError:
108
+ await proxy_command.finish(f"不是有效的 IPv4 地址:{value}")
109
+ saved, message = await manager.set_target_ip(target_ip)
110
+ await proxy_command.finish(message)
111
+
112
+ if action == "probe":
113
+ await set_emoji(bot, event, "427")
114
+ started, message = await manager.start("probe", bot, event)
115
+ if not started:
116
+ await proxy_command.finish(message)
117
+ return
118
+
119
+ if action == "refresh":
120
+ await set_emoji(bot, event, "294")
121
+ started, message = await manager.start("refresh", bot, event)
122
+ if not started:
123
+ await proxy_command.finish(message)
124
+ return
125
+
126
+ if action == "stop":
127
+ stopped, message = await manager.stop(bot, event)
128
+ if not stopped:
129
+ await proxy_command.finish(message)
130
+ return
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from nonebot import get_plugin_config
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class PluginConfig(BaseModel):
8
+ proxy_probe_local_ip: str = ""
9
+ proxy_probe_target_ip: str = ""
10
+ proxy_probe_prefix_length: int = 20
11
+ proxy_probe_ports: list[int] = Field(default_factory=lambda: [7897,7890]) # default_factory 可以明确避免多个实例共享同一个可变列表。
12
+ proxy_probe_connect_timeout: float = 0.35
13
+ proxy_probe_proxy_timeout: float = 5.0
14
+ proxy_probe_geo_timeout: float = 5.0
15
+ proxy_probe_workers: int = 256
16
+ proxy_probe_proxy_workers: int = 256
17
+ proxy_probe_geo_workers: int = 256
18
+ proxy_probe_bind_source_ip: bool = True
19
+ proxy_probe_test_urls: list[str] = Field(
20
+ default_factory=lambda: [
21
+ "https://api.ip.sb/ip",
22
+ "https://cp.cloudflare.com/generate_204",
23
+ "https://www.gstatic.com/generate_204",
24
+ ]
25
+ )
26
+ proxy_probe_exclude_ips: list[str] = Field(default_factory=list)
27
+
28
+
29
+ plugin_config = get_plugin_config(PluginConfig)
@@ -0,0 +1,360 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import copy
5
+ import threading
6
+ import time
7
+ from datetime import datetime
8
+
9
+ from nonebot.adapters.onebot.v11 import (
10
+ Bot,
11
+ GroupMessageEvent,
12
+ MessageEvent,
13
+ MessageSegment,
14
+ )
15
+ from nonebot.log import logger
16
+
17
+ from .cache import load_cache, load_target_ip, save_cache, save_target_ip
18
+ from .config import PluginConfig, plugin_config
19
+ from .models import CacheState, PipelineProgress, ProxyRecord
20
+ from .probe import (
21
+ ProbeConfig,
22
+ ProbeRunResult,
23
+ RefreshProgress,
24
+ RefreshRunResult,
25
+ detect_direct_public_ip,
26
+ detect_local_network,
27
+ run_probe,
28
+ run_refresh,
29
+ )
30
+ from .render import image_to_base64, render_cache_image
31
+
32
+
33
+ def format_time() -> str:
34
+ return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S")
35
+
36
+
37
+ def build_probe_config(
38
+ config: PluginConfig,
39
+ persisted_target_ip: str = "",
40
+ ) -> ProbeConfig:
41
+ network = detect_local_network(config.proxy_probe_local_ip.strip())
42
+ local_ip = network.local_ip
43
+ target_ip = (
44
+ persisted_target_ip.strip()
45
+ or config.proxy_probe_target_ip.strip()
46
+ )
47
+ if not target_ip:
48
+ target_ip = detect_direct_public_ip(
49
+ local_ip,
50
+ config.proxy_probe_geo_timeout,
51
+ config.proxy_probe_bind_source_ip,
52
+ network.dns_servers,
53
+ )
54
+ logger.info(
55
+ f"代理扫描使用网卡 {network.interface_name or '自动路由'} "
56
+ f"({local_ip}),目标参考 IP {target_ip}"
57
+ )
58
+ return ProbeConfig(
59
+ local_ip=local_ip,
60
+ target_ip=target_ip,
61
+ prefix_length=config.proxy_probe_prefix_length,
62
+ proxy_ports=tuple(config.proxy_probe_ports),
63
+ connect_timeout=config.proxy_probe_connect_timeout,
64
+ proxy_timeout=config.proxy_probe_proxy_timeout,
65
+ geo_timeout=config.proxy_probe_geo_timeout,
66
+ workers=config.proxy_probe_workers,
67
+ proxy_workers=config.proxy_probe_proxy_workers,
68
+ geo_workers=config.proxy_probe_geo_workers,
69
+ bind_source_ip=config.proxy_probe_bind_source_ip,
70
+ proxy_test_urls=tuple(config.proxy_probe_test_urls),
71
+ exclude_ips=tuple(config.proxy_probe_exclude_ips),
72
+ )
73
+
74
+
75
+ class ProbeManager:
76
+ def __init__(self) -> None:
77
+ self._state_lock = threading.RLock()
78
+ self._state = load_cache()
79
+ self._persisted_target_ip = load_target_ip()
80
+ self._task_lock = asyncio.Lock()
81
+ self._task: asyncio.Task[None] | None = None
82
+ self._stop_event: threading.Event | None = None
83
+ self._recipient: tuple[Bot, MessageEvent] | None = None
84
+ self._last_save = 0.0
85
+ self._persist_lock = threading.RLock()
86
+ environment_target_ip = plugin_config.proxy_probe_target_ip.strip()
87
+ if self._persisted_target_ip and environment_target_ip:
88
+ logger.warning(
89
+ "proxy_probe_target_ip 环境变量与 LocalStore 持久化设置"
90
+ "同时存在,将使用 LocalStore 缓存值 "
91
+ f"{self._persisted_target_ip},忽略环境变量值 "
92
+ f"{environment_target_ip}"
93
+ )
94
+ if self._state.running:
95
+ self._state.running = False
96
+ self._state.task_status = "任务因 Bot 重启而中止"
97
+ save_cache(self._state)
98
+
99
+ def get_state(self) -> CacheState:
100
+ with self._state_lock:
101
+ return copy.deepcopy(self._state)
102
+
103
+ def get_image_segment(self) -> MessageSegment:
104
+ image = render_cache_image(self.get_state())
105
+ return MessageSegment.image(image_to_base64(image))
106
+
107
+ def running_description(self) -> str:
108
+ state = self.get_state()
109
+ names = {"probe": "重新扫描", "refresh": "缓存刷新"}
110
+ return names.get(state.operation, "后台")
111
+
112
+ def configured_target_ip(self) -> str:
113
+ return (
114
+ self._persisted_target_ip
115
+ or plugin_config.proxy_probe_target_ip.strip()
116
+ )
117
+
118
+ async def set_target_ip(self, target_ip: str) -> tuple[bool, str]:
119
+ async with self._task_lock:
120
+ if self._task is not None and not self._task.done():
121
+ return (
122
+ False,
123
+ f"已有{self.running_description()}任务正在运行,"
124
+ "请先使用 /proxy -s 停止。",
125
+ )
126
+ try:
127
+ await asyncio.to_thread(save_target_ip, target_ip)
128
+ except (OSError, ValueError) as exc:
129
+ return False, f"保存目标 IP 失败:{exc}"
130
+ self._persisted_target_ip = target_ip
131
+ return (
132
+ True,
133
+ f"目标参考 IP 已持久化为 {target_ip},"
134
+ "后续扫描和刷新将优先使用该值。",
135
+ )
136
+
137
+ def _persist(self, force: bool = False) -> None:
138
+ with self._persist_lock:
139
+ now = time.monotonic()
140
+ if not force and now - self._last_save < 0.5:
141
+ return
142
+ with self._state_lock:
143
+ snapshot = copy.deepcopy(self._state)
144
+ try:
145
+ save_cache(snapshot)
146
+ self._last_save = now
147
+ except OSError as exc:
148
+ logger.warning(f"保存代理缓存失败: {exc}")
149
+
150
+ def _probe_callback(
151
+ self,
152
+ progress: PipelineProgress,
153
+ results: list[ProxyRecord],
154
+ current: int,
155
+ total: int,
156
+ ) -> None:
157
+ with self._state_lock:
158
+ self._state.progress = progress
159
+ self._state.results = list(results)
160
+ self._state.task_current = current
161
+ self._state.task_total = total
162
+ self._persist()
163
+
164
+ def _refresh_callback(
165
+ self,
166
+ results: list[ProxyRecord],
167
+ refresh: RefreshProgress,
168
+ ) -> None:
169
+ with self._state_lock:
170
+ self._state.results = list(results)
171
+ current = self._state.progress
172
+ self._state.progress = PipelineProgress(
173
+ total=current.total,
174
+ scan_completed=current.scan_completed,
175
+ open_count=current.open_count,
176
+ proxy_tested=refresh.proxy_tested,
177
+ proxy_count=refresh.proxy_count,
178
+ geo_tested=refresh.geo_tested,
179
+ geo_success=refresh.geo_success,
180
+ )
181
+ self._state.task_current = refresh.completed
182
+ self._state.task_total = refresh.total
183
+ self._persist()
184
+
185
+ async def start(
186
+ self,
187
+ operation: str,
188
+ bot: Bot,
189
+ event: MessageEvent,
190
+ ) -> tuple[bool, str]:
191
+ async with self._task_lock:
192
+ if self._task is not None and not self._task.done():
193
+ return (
194
+ False,
195
+ f"已有{self.running_description()}任务正在运行,"
196
+ "可使用 /proxy -s 停止。",
197
+ )
198
+ if operation == "refresh" and not self.get_state().results:
199
+ return False, "当前没有缓存代理,请先使用 /proxy -p 扫描。"
200
+
201
+ self._stop_event = threading.Event()
202
+ self._recipient = (bot, event)
203
+ with self._state_lock:
204
+ self._state.running = True
205
+ self._state.operation = operation
206
+ self._state.task_status = "运行中"
207
+ self._state.task_current = 0
208
+ if operation == "probe":
209
+ total = 1 << (32 - plugin_config.proxy_probe_prefix_length)
210
+ self._state.task_total = total
211
+ self._state.progress = PipelineProgress(total=total)
212
+ self._state.results = []
213
+ else:
214
+ self._state.task_total = len(self._state.results)
215
+ current = self._state.progress
216
+ self._state.progress = PipelineProgress(
217
+ total=current.total,
218
+ scan_completed=current.scan_completed,
219
+ open_count=current.open_count,
220
+ )
221
+ self._persist(force=True)
222
+ self._task = asyncio.create_task(
223
+ self._run(operation),
224
+ name=f"nonebot-proxy-{operation}",
225
+ )
226
+ return True, ""
227
+
228
+ async def stop(
229
+ self,
230
+ bot: Bot,
231
+ event: MessageEvent,
232
+ ) -> tuple[bool, str]:
233
+ async with self._task_lock:
234
+ task = self._task
235
+ stop_event = self._stop_event
236
+ if task is None or task.done() or stop_event is None:
237
+ return False, "当前没有正在运行的代理任务。"
238
+ self._recipient = (bot, event)
239
+ with self._state_lock:
240
+ self._state.task_status = "正在停止"
241
+ self._persist(force=True)
242
+ stop_event.set()
243
+ await asyncio.shield(task)
244
+ return True, ""
245
+
246
+ async def _run(self, operation: str) -> None:
247
+ stopped = False
248
+ error: Exception | None = None
249
+ origin_recipient = self._recipient
250
+ persisted_target_ip = self._persisted_target_ip
251
+ target_ip_auto_detected = not (
252
+ persisted_target_ip
253
+ or plugin_config.proxy_probe_target_ip.strip()
254
+ )
255
+ try:
256
+ config = await asyncio.to_thread(
257
+ build_probe_config,
258
+ plugin_config,
259
+ persisted_target_ip,
260
+ )
261
+ with self._state_lock:
262
+ self._state.local_ip = config.local_ip
263
+ self._state.target_ip = config.target_ip
264
+ self._persist(force=True)
265
+ if (
266
+ target_ip_auto_detected
267
+ and origin_recipient is not None
268
+ ):
269
+ bot, event = origin_recipient
270
+ if isinstance(event, GroupMessageEvent):
271
+ try:
272
+ await bot.call_api(
273
+ "set_msg_emoji_like",
274
+ group_id=event.group_id,
275
+ message_id=event.message_id,
276
+ emoji_id="4",
277
+ set=True,
278
+ )
279
+ except Exception as exc:
280
+ logger.warning(f"设置 IP 探测成功表情失败: {exc}")
281
+ stop_event = self._stop_event
282
+ if stop_event is None:
283
+ raise RuntimeError("停止事件未初始化")
284
+ if operation == "probe":
285
+ result: ProbeRunResult = await asyncio.to_thread(
286
+ run_probe,
287
+ config,
288
+ stop_event,
289
+ self._probe_callback,
290
+ )
291
+ stopped = result.interrupted
292
+ with self._state_lock:
293
+ self._state.progress = result.progress
294
+ self._state.results = result.results
295
+ self._state.task_current = result.progress.scan_completed
296
+ self._state.task_total = result.progress.total
297
+ completed_at = format_time()
298
+ self._state.scan_time = completed_at
299
+ self._state.refresh_time = completed_at
300
+ else:
301
+ cached = self.get_state().results
302
+ refresh_result: RefreshRunResult = await asyncio.to_thread(
303
+ run_refresh,
304
+ config,
305
+ cached,
306
+ stop_event,
307
+ self._refresh_callback,
308
+ )
309
+ stopped = refresh_result.interrupted
310
+ with self._state_lock:
311
+ self._state.results = refresh_result.results
312
+ refresh = refresh_result.progress
313
+ current = self._state.progress
314
+ self._state.progress = PipelineProgress(
315
+ total=current.total,
316
+ scan_completed=current.scan_completed,
317
+ open_count=current.open_count,
318
+ proxy_tested=refresh.proxy_tested,
319
+ proxy_count=refresh.proxy_count,
320
+ geo_tested=refresh.geo_tested,
321
+ geo_success=refresh.geo_success,
322
+ )
323
+ self._state.task_current = refresh.completed
324
+ self._state.task_total = refresh.total
325
+ self._state.refresh_time = format_time()
326
+ except Exception as exc:
327
+ error = exc
328
+ logger.exception("代理探测后台任务失败")
329
+ finally:
330
+ with self._state_lock:
331
+ self._state.running = False
332
+ if error is not None:
333
+ self._state.task_status = f"失败:{error}"
334
+ elif stopped:
335
+ self._state.task_status = "已停止,显示部分结果"
336
+ else:
337
+ self._state.task_status = "已完成"
338
+ snapshot = copy.deepcopy(self._state)
339
+ try:
340
+ save_cache(snapshot)
341
+ except OSError as exc:
342
+ logger.warning(f"保存代理最终缓存失败: {exc}")
343
+
344
+ recipient = self._recipient
345
+ if recipient is not None:
346
+ bot, event = recipient
347
+ try:
348
+ if error is not None:
349
+ await bot.send(event, f"代理任务失败:{error}")
350
+ await bot.send(event, self.get_image_segment())
351
+ except Exception:
352
+ logger.exception("发送代理结果图片失败")
353
+
354
+ async with self._task_lock:
355
+ self._task = None
356
+ self._stop_event = None
357
+ self._recipient = None
358
+
359
+
360
+ manager = ProbeManager()