nonebot-plugin-ts3-tracker 1.0.0__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.
- nonebot_plugin_ts3_tracker/__init__.py +87 -0
- nonebot_plugin_ts3_tracker/config.py +69 -0
- nonebot_plugin_ts3_tracker/models.py +26 -0
- nonebot_plugin_ts3_tracker/query.py +275 -0
- nonebot_plugin_ts3_tracker/runtime.py +336 -0
- nonebot_plugin_ts3_tracker/service.py +145 -0
- nonebot_plugin_ts3_tracker/storage.py +47 -0
- nonebot_plugin_ts3_tracker-1.0.0.dist-info/METADATA +235 -0
- nonebot_plugin_ts3_tracker-1.0.0.dist-info/RECORD +10 -0
- nonebot_plugin_ts3_tracker-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from nonebot import get_driver, get_plugin_config, logger, on_command, on_regex
|
|
6
|
+
from nonebot.adapters.onebot.v11 import GroupMessageEvent, MessageEvent
|
|
7
|
+
from nonebot.plugin import PluginMetadata
|
|
8
|
+
|
|
9
|
+
from .config import Config
|
|
10
|
+
from .runtime import Ts3TrackerRuntime
|
|
11
|
+
from .service import Ts3TrackerService
|
|
12
|
+
|
|
13
|
+
plugin_config = get_plugin_config(Config).ts3_tracker
|
|
14
|
+
service = Ts3TrackerService(plugin_config)
|
|
15
|
+
runtime = Ts3TrackerRuntime(plugin_config, service)
|
|
16
|
+
|
|
17
|
+
__plugin_meta__ = PluginMetadata(
|
|
18
|
+
name="TS3 Tracker",
|
|
19
|
+
description="查询 TeamSpeak 3 服务器在线状态与频道在线成员。",
|
|
20
|
+
usage=(
|
|
21
|
+
"上号\n"
|
|
22
|
+
"/ts\n"
|
|
23
|
+
"/tsinfo\n\n"
|
|
24
|
+
"可选:开启轮询后发送 TS3 进服/退服通知"
|
|
25
|
+
),
|
|
26
|
+
type="application",
|
|
27
|
+
homepage="https://github.com/moeneri/nonebot-plugin-ts3-tracker",
|
|
28
|
+
config=Config,
|
|
29
|
+
supported_adapters={"nonebot.adapters.onebot.v11"},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ensure_group_allowed(event: MessageEvent) -> str | None:
|
|
34
|
+
if not isinstance(event, GroupMessageEvent):
|
|
35
|
+
return None
|
|
36
|
+
if plugin_config.is_group_allowed(event.group_id):
|
|
37
|
+
return None
|
|
38
|
+
return "当前群未开启 TS3 查询白名单权限。"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ts3_status = on_command(
|
|
42
|
+
"上号",
|
|
43
|
+
aliases={"ts", "tsinfo"},
|
|
44
|
+
priority=plugin_config.command_priority,
|
|
45
|
+
block=True,
|
|
46
|
+
)
|
|
47
|
+
ts3_status_regex = on_regex(
|
|
48
|
+
r"^(?:/)?(?:上号|ts|tsinfo)$",
|
|
49
|
+
flags=re.IGNORECASE,
|
|
50
|
+
priority=plugin_config.command_priority,
|
|
51
|
+
block=True,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@ts3_status.handle()
|
|
56
|
+
async def handle_ts3_status(event: MessageEvent) -> None:
|
|
57
|
+
denied_message = _ensure_group_allowed(event)
|
|
58
|
+
if denied_message is not None:
|
|
59
|
+
await ts3_status.finish(denied_message)
|
|
60
|
+
|
|
61
|
+
group_id = getattr(event, "group_id", None)
|
|
62
|
+
logger.info(
|
|
63
|
+
"群号 {} 查询了服务器信息。",
|
|
64
|
+
group_id if group_id is not None else event.get_session_id(),
|
|
65
|
+
)
|
|
66
|
+
message = await service.build_server_message()
|
|
67
|
+
await ts3_status.finish(message)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@ts3_status_regex.handle()
|
|
71
|
+
async def handle_ts3_status_regex(event: MessageEvent) -> None:
|
|
72
|
+
denied_message = _ensure_group_allowed(event)
|
|
73
|
+
if denied_message is not None:
|
|
74
|
+
await ts3_status_regex.finish(denied_message)
|
|
75
|
+
|
|
76
|
+
group_id = getattr(event, "group_id", None)
|
|
77
|
+
logger.info(
|
|
78
|
+
"群号 {} 查询了服务器信息。",
|
|
79
|
+
group_id if group_id is not None else event.get_session_id(),
|
|
80
|
+
)
|
|
81
|
+
message = await service.build_server_message()
|
|
82
|
+
await ts3_status_regex.finish(message)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
driver = get_driver()
|
|
86
|
+
driver.on_startup(runtime.startup)
|
|
87
|
+
driver.on_shutdown(runtime.shutdown)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, field_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Ts3TrackerSettings(BaseModel):
|
|
7
|
+
server_host: str = ""
|
|
8
|
+
server_port: int = 9987
|
|
9
|
+
serverquery_port: int = 10011
|
|
10
|
+
serverquery_username: str = ""
|
|
11
|
+
serverquery_password: str = ""
|
|
12
|
+
debug: bool = False
|
|
13
|
+
command_priority: int = 10
|
|
14
|
+
query_timeout_seconds: float = 10.0
|
|
15
|
+
notification_enabled: bool = False
|
|
16
|
+
notify_target_groups: str = ""
|
|
17
|
+
notify_target_users: str = ""
|
|
18
|
+
notify_bot_id: str = ""
|
|
19
|
+
group_whitelist_enabled: bool = False
|
|
20
|
+
group_whitelist_groups: str = ""
|
|
21
|
+
poll_interval_seconds: int = 5
|
|
22
|
+
startup_silent: bool = True
|
|
23
|
+
data_dir: str = ""
|
|
24
|
+
|
|
25
|
+
@field_validator(
|
|
26
|
+
"server_host",
|
|
27
|
+
"serverquery_username",
|
|
28
|
+
"serverquery_password",
|
|
29
|
+
"notify_target_groups",
|
|
30
|
+
"notify_target_users",
|
|
31
|
+
"notify_bot_id",
|
|
32
|
+
"group_whitelist_groups",
|
|
33
|
+
"data_dir",
|
|
34
|
+
mode="before",
|
|
35
|
+
)
|
|
36
|
+
@classmethod
|
|
37
|
+
def strip_text(cls, value: object) -> str:
|
|
38
|
+
return str(value).strip()
|
|
39
|
+
|
|
40
|
+
@field_validator("command_priority", "poll_interval_seconds")
|
|
41
|
+
@classmethod
|
|
42
|
+
def validate_positive_int(cls, value: int) -> int:
|
|
43
|
+
return max(1, value)
|
|
44
|
+
|
|
45
|
+
@field_validator("query_timeout_seconds")
|
|
46
|
+
@classmethod
|
|
47
|
+
def validate_timeout(cls, value: float) -> float:
|
|
48
|
+
return max(1.0, value)
|
|
49
|
+
|
|
50
|
+
def parse_targets(self, raw: str) -> list[str]:
|
|
51
|
+
normalized = raw.replace("\r", "\n").replace(";", "\n").replace(",", "\n")
|
|
52
|
+
targets = [item.strip() for item in normalized.split("\n")]
|
|
53
|
+
return [item for item in targets if item]
|
|
54
|
+
|
|
55
|
+
def is_group_allowed(self, group_id: str | int | None) -> bool:
|
|
56
|
+
if group_id is None or not self.group_whitelist_enabled:
|
|
57
|
+
return True
|
|
58
|
+
return str(group_id) in set(self.parse_targets(self.group_whitelist_groups))
|
|
59
|
+
|
|
60
|
+
def get_effective_notify_groups(self) -> list[str]:
|
|
61
|
+
notify_groups = self.parse_targets(self.notify_target_groups)
|
|
62
|
+
if not self.group_whitelist_enabled:
|
|
63
|
+
return notify_groups
|
|
64
|
+
whitelist = set(self.parse_targets(self.group_whitelist_groups))
|
|
65
|
+
return [group_id for group_id in notify_groups if group_id in whitelist]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Config(BaseModel):
|
|
69
|
+
ts3_tracker: Ts3TrackerSettings = Field(default_factory=Ts3TrackerSettings)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class Ts3OnlineUser:
|
|
8
|
+
nickname: str
|
|
9
|
+
channel_id: str
|
|
10
|
+
channel_name: str
|
|
11
|
+
client_id: str
|
|
12
|
+
database_id: str
|
|
13
|
+
unique_id: str
|
|
14
|
+
client_ip: str
|
|
15
|
+
connected_duration_seconds: int
|
|
16
|
+
away: bool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class Ts3ServerStatus:
|
|
21
|
+
server_name: str
|
|
22
|
+
server_host: str
|
|
23
|
+
server_port: int
|
|
24
|
+
online_count: int
|
|
25
|
+
channels: list[tuple[str, str]]
|
|
26
|
+
users: list[Ts3OnlineUser]
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from nonebot import logger
|
|
8
|
+
|
|
9
|
+
from .models import Ts3OnlineUser, Ts3ServerStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Ts3QueryError(Exception):
|
|
13
|
+
"""Raised when a ServerQuery request fails."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
ESCAPE_MAP = {
|
|
17
|
+
"\\": "\\\\",
|
|
18
|
+
"/": "\\/",
|
|
19
|
+
" ": "\\s",
|
|
20
|
+
"|": "\\p",
|
|
21
|
+
"\a": "\\a",
|
|
22
|
+
"\b": "\\b",
|
|
23
|
+
"\f": "\\f",
|
|
24
|
+
"\n": "\\n",
|
|
25
|
+
"\r": "\\r",
|
|
26
|
+
"\t": "\\t",
|
|
27
|
+
"\v": "\\v",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
UNESCAPE_MAP = {
|
|
31
|
+
"\\\\": "\\",
|
|
32
|
+
"\\/": "/",
|
|
33
|
+
"\\s": " ",
|
|
34
|
+
"\\p": "|",
|
|
35
|
+
"\\a": "\a",
|
|
36
|
+
"\\b": "\b",
|
|
37
|
+
"\\f": "\f",
|
|
38
|
+
"\\n": "\n",
|
|
39
|
+
"\\r": "\r",
|
|
40
|
+
"\\t": "\t",
|
|
41
|
+
"\\v": "\v",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Ts3QueryClient:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
host: str,
|
|
49
|
+
server_port: int,
|
|
50
|
+
username: str,
|
|
51
|
+
password: str,
|
|
52
|
+
query_port: int = 10011,
|
|
53
|
+
timeout: float = 10.0,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.host = host
|
|
56
|
+
self.server_port = server_port
|
|
57
|
+
self.query_port = query_port
|
|
58
|
+
self.username = username
|
|
59
|
+
self.password = password
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
|
|
62
|
+
async def fetch_status(self) -> Ts3ServerStatus:
|
|
63
|
+
try:
|
|
64
|
+
return await asyncio.wait_for(self._fetch_status_inner(), timeout=self.timeout)
|
|
65
|
+
except asyncio.TimeoutError as exc:
|
|
66
|
+
raise Ts3QueryError(f"TS3 查询超时({self.timeout:.0f} 秒)") from exc
|
|
67
|
+
|
|
68
|
+
async def _fetch_status_inner(self) -> Ts3ServerStatus:
|
|
69
|
+
try:
|
|
70
|
+
reader, writer = await asyncio.open_connection(self.host, self.query_port)
|
|
71
|
+
except Exception as exc: # pragma: no cover - network dependent
|
|
72
|
+
raise Ts3QueryError(
|
|
73
|
+
f"无法连接到 ServerQuery:{self.host}:{self.query_port} ({exc})"
|
|
74
|
+
) from exc
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
await self._consume_welcome(reader)
|
|
78
|
+
await self._execute(
|
|
79
|
+
reader,
|
|
80
|
+
writer,
|
|
81
|
+
f"login {self._escape(self.username)} {self._escape(self.password)}",
|
|
82
|
+
"login",
|
|
83
|
+
)
|
|
84
|
+
await self._execute(reader, writer, f"use port={self.server_port}", "use")
|
|
85
|
+
serverinfo_records = await self._execute(
|
|
86
|
+
reader, writer, "serverinfo", "serverinfo"
|
|
87
|
+
)
|
|
88
|
+
channel_records = await self._execute(
|
|
89
|
+
reader, writer, "channellist", "channellist"
|
|
90
|
+
)
|
|
91
|
+
client_records = await self._execute(
|
|
92
|
+
reader,
|
|
93
|
+
writer,
|
|
94
|
+
"clientlist -uid -away -ip -times",
|
|
95
|
+
"clientlist",
|
|
96
|
+
)
|
|
97
|
+
await self._write_line(writer, "quit")
|
|
98
|
+
finally:
|
|
99
|
+
writer.close()
|
|
100
|
+
with contextlib.suppress(Exception):
|
|
101
|
+
await writer.wait_closed()
|
|
102
|
+
|
|
103
|
+
serverinfo = serverinfo_records[0] if serverinfo_records else {}
|
|
104
|
+
channels = {
|
|
105
|
+
channel.get("cid", ""): channel.get("channel_name", "")
|
|
106
|
+
for channel in channel_records
|
|
107
|
+
}
|
|
108
|
+
channel_order = [
|
|
109
|
+
(channel.get("cid", ""), channel.get("channel_name", ""))
|
|
110
|
+
for channel in channel_records
|
|
111
|
+
if channel.get("cid", "")
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
users: list[Ts3OnlineUser] = []
|
|
115
|
+
for client in client_records:
|
|
116
|
+
if client.get("client_type") == "1":
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
users.append(
|
|
120
|
+
Ts3OnlineUser(
|
|
121
|
+
nickname=client.get("client_nickname", ""),
|
|
122
|
+
channel_id=client.get("cid", ""),
|
|
123
|
+
channel_name=channels.get(client.get("cid", ""), ""),
|
|
124
|
+
client_id=client.get("clid", ""),
|
|
125
|
+
database_id=client.get("client_database_id", ""),
|
|
126
|
+
unique_id=client.get("client_unique_identifier", ""),
|
|
127
|
+
client_ip=client.get("connection_client_ip", ""),
|
|
128
|
+
connected_duration_seconds=max(
|
|
129
|
+
0,
|
|
130
|
+
self._safe_int(client.get("connection_connected_time"), 0) // 1000,
|
|
131
|
+
),
|
|
132
|
+
away=client.get("client_away", "0") == "1",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
users.sort(key=lambda item: item.nickname.casefold())
|
|
137
|
+
server_port = self._safe_int(
|
|
138
|
+
serverinfo.get("virtualserver_port"), self.server_port
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return Ts3ServerStatus(
|
|
142
|
+
server_name=serverinfo.get("virtualserver_name", ""),
|
|
143
|
+
server_host=self.host,
|
|
144
|
+
server_port=server_port,
|
|
145
|
+
online_count=len(users),
|
|
146
|
+
channels=channel_order,
|
|
147
|
+
users=users,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def _execute(
|
|
151
|
+
self,
|
|
152
|
+
reader: asyncio.StreamReader,
|
|
153
|
+
writer: asyncio.StreamWriter,
|
|
154
|
+
command: str,
|
|
155
|
+
action: str,
|
|
156
|
+
) -> list[dict[str, str]]:
|
|
157
|
+
await self._write_line(writer, command)
|
|
158
|
+
lines = await self._read_response(reader)
|
|
159
|
+
return self._parse_response(lines, action)
|
|
160
|
+
|
|
161
|
+
async def _write_line(self, writer: asyncio.StreamWriter, line: str) -> None:
|
|
162
|
+
writer.write(f"{line}\n".encode("utf-8"))
|
|
163
|
+
await writer.drain()
|
|
164
|
+
|
|
165
|
+
async def _consume_welcome(self, reader: asyncio.StreamReader) -> None:
|
|
166
|
+
while True:
|
|
167
|
+
raw_line = await reader.readline()
|
|
168
|
+
if not raw_line:
|
|
169
|
+
logger.warning("TS3 ServerQuery welcome banner ended unexpectedly")
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
line = raw_line.decode("utf-8", errors="replace").strip("\r\n")
|
|
173
|
+
if not line:
|
|
174
|
+
continue
|
|
175
|
+
if line.startswith("error "):
|
|
176
|
+
return
|
|
177
|
+
if line == "TS3":
|
|
178
|
+
continue
|
|
179
|
+
if "TeamSpeak 3 ServerQuery interface" in line:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
logger.debug("TS3 ServerQuery welcome line: {}", line)
|
|
183
|
+
|
|
184
|
+
async def _read_response(self, reader: asyncio.StreamReader) -> list[str]:
|
|
185
|
+
lines: list[str] = []
|
|
186
|
+
while True:
|
|
187
|
+
try:
|
|
188
|
+
raw_line = await reader.readline()
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
raise Ts3QueryError(f"ServerQuery 读取响应失败:{exc}") from exc
|
|
191
|
+
|
|
192
|
+
if not raw_line:
|
|
193
|
+
if lines:
|
|
194
|
+
return lines
|
|
195
|
+
raise Ts3QueryError("ServerQuery 连接已关闭")
|
|
196
|
+
|
|
197
|
+
line = raw_line.decode("utf-8", errors="replace").strip("\r\n")
|
|
198
|
+
if not line:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
lines.append(line)
|
|
202
|
+
if line.startswith("error "):
|
|
203
|
+
return lines
|
|
204
|
+
|
|
205
|
+
def _parse_response(
|
|
206
|
+
self, lines: Sequence[str], action: str
|
|
207
|
+
) -> list[dict[str, str]]:
|
|
208
|
+
if not lines:
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
error_line = lines[-1]
|
|
212
|
+
if not error_line.startswith("error "):
|
|
213
|
+
raise Ts3QueryError(f"{action} 失败:响应格式异常,缺少 error 行")
|
|
214
|
+
|
|
215
|
+
error_info = self._parse_record(error_line.removeprefix("error "))
|
|
216
|
+
try:
|
|
217
|
+
error_id = int(error_info.get("id", "-1"))
|
|
218
|
+
except (TypeError, ValueError) as exc:
|
|
219
|
+
raise Ts3QueryError(
|
|
220
|
+
f"{action} 失败:响应格式异常,error id 无法解析"
|
|
221
|
+
) from exc
|
|
222
|
+
|
|
223
|
+
if error_id != 0:
|
|
224
|
+
error_msg = error_info.get("msg", "unknown")
|
|
225
|
+
raise Ts3QueryError(f"{action} 失败:{error_msg} (id={error_id})")
|
|
226
|
+
|
|
227
|
+
if len(lines) == 1:
|
|
228
|
+
return []
|
|
229
|
+
|
|
230
|
+
data = "\n".join(lines[:-1]).strip()
|
|
231
|
+
if not data:
|
|
232
|
+
return []
|
|
233
|
+
|
|
234
|
+
records: list[dict[str, str]] = []
|
|
235
|
+
for raw_record in data.split("|"):
|
|
236
|
+
record = raw_record.strip()
|
|
237
|
+
if record:
|
|
238
|
+
records.append(self._parse_record(record))
|
|
239
|
+
return records
|
|
240
|
+
|
|
241
|
+
def _parse_record(self, payload: str) -> dict[str, str]:
|
|
242
|
+
record: dict[str, str] = {}
|
|
243
|
+
for token in payload.split(" "):
|
|
244
|
+
if not token:
|
|
245
|
+
continue
|
|
246
|
+
if "=" not in token:
|
|
247
|
+
record[token] = ""
|
|
248
|
+
continue
|
|
249
|
+
key, value = token.split("=", 1)
|
|
250
|
+
record[key] = self._unescape(value)
|
|
251
|
+
return record
|
|
252
|
+
|
|
253
|
+
def _escape(self, value: str) -> str:
|
|
254
|
+
return "".join(ESCAPE_MAP.get(char, char) for char in value)
|
|
255
|
+
|
|
256
|
+
def _unescape(self, value: str) -> str:
|
|
257
|
+
chars: list[str] = []
|
|
258
|
+
index = 0
|
|
259
|
+
while index < len(value):
|
|
260
|
+
if value[index] != "\\" or index + 1 >= len(value):
|
|
261
|
+
chars.append(value[index])
|
|
262
|
+
index += 1
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
escaped = value[index : index + 2]
|
|
266
|
+
chars.append(UNESCAPE_MAP.get(escaped, escaped[1]))
|
|
267
|
+
index += 2
|
|
268
|
+
|
|
269
|
+
return "".join(chars)
|
|
270
|
+
|
|
271
|
+
def _safe_int(self, value: object, default: int) -> int:
|
|
272
|
+
try:
|
|
273
|
+
return int(value)
|
|
274
|
+
except (TypeError, ValueError):
|
|
275
|
+
return default
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import nonebot
|
|
10
|
+
from nonebot import logger, require
|
|
11
|
+
from nonebot.adapters.onebot.v11 import Bot
|
|
12
|
+
|
|
13
|
+
require("nonebot_plugin_localstore")
|
|
14
|
+
import nonebot_plugin_localstore as store
|
|
15
|
+
|
|
16
|
+
from .config import Ts3TrackerSettings
|
|
17
|
+
from .models import Ts3OnlineUser, Ts3ServerStatus
|
|
18
|
+
from .query import Ts3QueryError
|
|
19
|
+
from .service import Ts3TrackerService
|
|
20
|
+
from .storage import SnapshotStore, TrackedClientSnapshot
|
|
21
|
+
|
|
22
|
+
MessageSender = Callable[[str, str, str], Awaitable[bool]]
|
|
23
|
+
NowFactory = Callable[[], datetime]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class NotificationDiff:
|
|
28
|
+
joined: list[TrackedClientSnapshot]
|
|
29
|
+
left: list[TrackedClientSnapshot]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Ts3TrackerRuntime:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
settings: Ts3TrackerSettings,
|
|
36
|
+
service: Ts3TrackerService,
|
|
37
|
+
*,
|
|
38
|
+
store_backend: SnapshotStore | None = None,
|
|
39
|
+
message_sender: MessageSender | None = None,
|
|
40
|
+
now_factory: NowFactory | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.settings = settings
|
|
43
|
+
self.service = service
|
|
44
|
+
self._store = store_backend or SnapshotStore(self._build_snapshot_file())
|
|
45
|
+
self._message_sender = message_sender or self._send_message
|
|
46
|
+
self._now_factory = now_factory or datetime.now
|
|
47
|
+
self._snapshot: dict[str, TrackedClientSnapshot] = {}
|
|
48
|
+
self._snapshot_lock = asyncio.Lock()
|
|
49
|
+
self._stop_event = asyncio.Event()
|
|
50
|
+
self._poll_task: asyncio.Task[None] | None = None
|
|
51
|
+
self.service._duration_provider = self.get_online_duration_seconds
|
|
52
|
+
|
|
53
|
+
async def startup(self) -> None:
|
|
54
|
+
self._stop_event.clear()
|
|
55
|
+
try:
|
|
56
|
+
self._snapshot = self._store.load()
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
logger.error("failed to load ts3 snapshot store: {}", exc)
|
|
59
|
+
self._snapshot = {}
|
|
60
|
+
|
|
61
|
+
if not self.settings.notification_enabled:
|
|
62
|
+
logger.info("TS3 通知轮询已关闭。")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
logger.info(
|
|
66
|
+
"TS3 通知轮询已启动,轮询间隔 {} 秒,通知群:{},通知私聊:{},群白名单模式:{}。",
|
|
67
|
+
self.settings.poll_interval_seconds,
|
|
68
|
+
",".join(self.settings.get_effective_notify_groups()) or "-",
|
|
69
|
+
self.settings.notify_target_users or "-",
|
|
70
|
+
"开启" if self.settings.group_whitelist_enabled else "关闭",
|
|
71
|
+
)
|
|
72
|
+
await self.sync_once(notify=not self.settings.startup_silent)
|
|
73
|
+
self._ensure_poll_task()
|
|
74
|
+
|
|
75
|
+
async def shutdown(self) -> None:
|
|
76
|
+
self._stop_event.set()
|
|
77
|
+
if self._poll_task is not None:
|
|
78
|
+
self._poll_task.cancel()
|
|
79
|
+
try:
|
|
80
|
+
await self._poll_task
|
|
81
|
+
except asyncio.CancelledError:
|
|
82
|
+
pass
|
|
83
|
+
self._poll_task = None
|
|
84
|
+
|
|
85
|
+
async def sync_once(self, *, notify: bool) -> NotificationDiff:
|
|
86
|
+
missing_fields = self.service.get_missing_required_fields()
|
|
87
|
+
if missing_fields:
|
|
88
|
+
logger.warning(
|
|
89
|
+
"TS3 通知轮询跳过,配置不完整:{}",
|
|
90
|
+
"、".join(missing_fields),
|
|
91
|
+
)
|
|
92
|
+
return NotificationDiff(joined=[], left=[])
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
status = await self.service.fetch_status()
|
|
96
|
+
except Ts3QueryError as exc:
|
|
97
|
+
logger.warning("TS3 轮询失败:{}", exc)
|
|
98
|
+
return NotificationDiff(joined=[], left=[])
|
|
99
|
+
except Exception as exc: # pragma: no cover
|
|
100
|
+
logger.exception("TS3 轮询发生未预期错误:{}", exc)
|
|
101
|
+
return NotificationDiff(joined=[], left=[])
|
|
102
|
+
|
|
103
|
+
current = self._build_snapshot(status)
|
|
104
|
+
async with self._snapshot_lock:
|
|
105
|
+
diff = self._calculate_diff(self._snapshot, current)
|
|
106
|
+
self._snapshot = current
|
|
107
|
+
try:
|
|
108
|
+
self._store.save(self._snapshot)
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
logger.error("保存 TS3 快照失败:{}", exc)
|
|
111
|
+
|
|
112
|
+
if notify:
|
|
113
|
+
await self._dispatch_notifications(status, diff)
|
|
114
|
+
elif diff.joined or diff.left:
|
|
115
|
+
logger.info(
|
|
116
|
+
"TS3 首次同步完成,不发送通知。进入:{},离开:{}。",
|
|
117
|
+
"、".join(item.nickname for item in diff.joined) or "无",
|
|
118
|
+
"、".join(item.nickname for item in diff.left) or "无",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return diff
|
|
122
|
+
|
|
123
|
+
def _ensure_poll_task(self) -> None:
|
|
124
|
+
if self._poll_task is not None and not self._poll_task.done():
|
|
125
|
+
return
|
|
126
|
+
self._poll_task = asyncio.create_task(self._poll_loop())
|
|
127
|
+
|
|
128
|
+
async def _poll_loop(self) -> None:
|
|
129
|
+
while not self._stop_event.is_set():
|
|
130
|
+
try:
|
|
131
|
+
await self.sync_once(notify=True)
|
|
132
|
+
except asyncio.CancelledError:
|
|
133
|
+
raise
|
|
134
|
+
except Exception as exc: # pragma: no cover
|
|
135
|
+
logger.error("TS3 轮询循环异常:{}", exc)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
await asyncio.wait_for(
|
|
139
|
+
self._stop_event.wait(),
|
|
140
|
+
timeout=self.settings.poll_interval_seconds,
|
|
141
|
+
)
|
|
142
|
+
except asyncio.TimeoutError:
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
def _build_snapshot(
|
|
146
|
+
self, status: Ts3ServerStatus
|
|
147
|
+
) -> dict[str, TrackedClientSnapshot]:
|
|
148
|
+
snapshots: dict[str, TrackedClientSnapshot] = {}
|
|
149
|
+
now_text = self._format_now()
|
|
150
|
+
for user in status.users:
|
|
151
|
+
key = self._user_key(user)
|
|
152
|
+
previous = self._snapshot.get(key)
|
|
153
|
+
snapshots[key] = TrackedClientSnapshot(
|
|
154
|
+
nickname=user.nickname,
|
|
155
|
+
unique_id=user.unique_id,
|
|
156
|
+
channel_id=user.channel_id,
|
|
157
|
+
channel_name=user.channel_name,
|
|
158
|
+
connected_duration_seconds=user.connected_duration_seconds,
|
|
159
|
+
away=user.away,
|
|
160
|
+
first_seen_at=(
|
|
161
|
+
previous.first_seen_at if previous and previous.first_seen_at else now_text
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
return snapshots
|
|
165
|
+
|
|
166
|
+
def _calculate_diff(
|
|
167
|
+
self,
|
|
168
|
+
previous: dict[str, TrackedClientSnapshot],
|
|
169
|
+
current: dict[str, TrackedClientSnapshot],
|
|
170
|
+
) -> NotificationDiff:
|
|
171
|
+
joined = [current[key] for key in current.keys() - previous.keys()]
|
|
172
|
+
left = [previous[key] for key in previous.keys() - current.keys()]
|
|
173
|
+
joined.sort(key=lambda item: item.nickname.casefold())
|
|
174
|
+
left.sort(key=lambda item: item.nickname.casefold())
|
|
175
|
+
return NotificationDiff(joined=joined, left=left)
|
|
176
|
+
|
|
177
|
+
async def _dispatch_notifications(
|
|
178
|
+
self, status: Ts3ServerStatus, diff: NotificationDiff
|
|
179
|
+
) -> None:
|
|
180
|
+
messages: list[str] = []
|
|
181
|
+
if diff.joined:
|
|
182
|
+
logger.info(
|
|
183
|
+
"{} 进入了服务器。",
|
|
184
|
+
"、".join(item.nickname for item in diff.joined),
|
|
185
|
+
)
|
|
186
|
+
messages.append(self._format_join_message(status, diff.joined))
|
|
187
|
+
if diff.left:
|
|
188
|
+
logger.info(
|
|
189
|
+
"{} 退出了服务器。",
|
|
190
|
+
"、".join(item.nickname for item in diff.left),
|
|
191
|
+
)
|
|
192
|
+
messages.append(self._format_leave_message(status, diff.left))
|
|
193
|
+
if not messages:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
targets = [
|
|
197
|
+
("group", target)
|
|
198
|
+
for target in self.settings.get_effective_notify_groups()
|
|
199
|
+
]
|
|
200
|
+
targets.extend(
|
|
201
|
+
("private", target)
|
|
202
|
+
for target in self.settings.parse_targets(self.settings.notify_target_users)
|
|
203
|
+
)
|
|
204
|
+
if not targets:
|
|
205
|
+
logger.warning("检测到 TS3 变化,但没有可用的通知目标。")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
for message in messages:
|
|
209
|
+
for target_type, target in targets:
|
|
210
|
+
ok = await self._message_sender(target_type, target, message)
|
|
211
|
+
if not ok:
|
|
212
|
+
logger.warning(
|
|
213
|
+
"发送 TS3 通知失败,目标类型:{},目标:{}。",
|
|
214
|
+
target_type,
|
|
215
|
+
target,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _format_join_message(
|
|
219
|
+
self, status: Ts3ServerStatus, snapshots: list[TrackedClientSnapshot]
|
|
220
|
+
) -> str:
|
|
221
|
+
lines = [
|
|
222
|
+
"让我看看是谁还没上号 👀",
|
|
223
|
+
]
|
|
224
|
+
for snapshot in snapshots:
|
|
225
|
+
lines.append(f"🧾 昵称:{snapshot.nickname}")
|
|
226
|
+
lines.append(f"🟢 上线时间:{snapshot.first_seen_at or self._format_now()}")
|
|
227
|
+
lines.append(f"📣 {snapshot.nickname} 进入了 TS 服务器")
|
|
228
|
+
lines.append(f"👥 当前在线人数:{status.online_count}")
|
|
229
|
+
lines.append(f"📜 在线列表:{self._format_online_list(status)}")
|
|
230
|
+
return "\n".join(lines)
|
|
231
|
+
|
|
232
|
+
def _format_leave_message(
|
|
233
|
+
self, status: Ts3ServerStatus, snapshots: list[TrackedClientSnapshot]
|
|
234
|
+
) -> str:
|
|
235
|
+
lines = [
|
|
236
|
+
"📤 用户下线通知",
|
|
237
|
+
]
|
|
238
|
+
for snapshot in snapshots:
|
|
239
|
+
duration_text = self._format_online_duration(snapshot)
|
|
240
|
+
lines.append(f"🧾 昵称:{snapshot.nickname}")
|
|
241
|
+
lines.append(f"🟢 上线时间:{snapshot.first_seen_at or self._format_now()}")
|
|
242
|
+
lines.append(f"🔴 下线时间:{self._format_now()}")
|
|
243
|
+
lines.append(f"⏱️ 在线时长:{duration_text}")
|
|
244
|
+
lines.append(f"👥 当前在线人数:{status.online_count}")
|
|
245
|
+
lines.append(f"📜 在线列表:{self._format_online_list(status)}")
|
|
246
|
+
return "\n".join(lines)
|
|
247
|
+
|
|
248
|
+
async def _send_message(self, target_type: str, target: str, message: str) -> bool:
|
|
249
|
+
bot = self._select_bot()
|
|
250
|
+
if bot is None:
|
|
251
|
+
logger.warning("没有可用的 OneBot V11 机器人,无法发送 TS3 主动通知。")
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
normalized_target = int(target) if target.isdigit() else target
|
|
256
|
+
if target_type == "group":
|
|
257
|
+
await bot.send_group_msg(group_id=normalized_target, message=message)
|
|
258
|
+
else:
|
|
259
|
+
await bot.send_private_msg(user_id=normalized_target, message=message)
|
|
260
|
+
return True
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
logger.error("发送 TS3 主动{}消息失败:{}", target_type, exc)
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
def _select_bot(self) -> Bot | None:
|
|
266
|
+
bots = nonebot.get_bots()
|
|
267
|
+
if self.settings.notify_bot_id:
|
|
268
|
+
bot = bots.get(self.settings.notify_bot_id)
|
|
269
|
+
if isinstance(bot, Bot):
|
|
270
|
+
return bot
|
|
271
|
+
for bot in bots.values():
|
|
272
|
+
if isinstance(bot, Bot):
|
|
273
|
+
return bot
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
def _build_snapshot_file(self) -> Path:
|
|
277
|
+
if self.settings.data_dir:
|
|
278
|
+
return Path(self.settings.data_dir) / "snapshot.json"
|
|
279
|
+
return store.get_plugin_data_file("snapshot.json")
|
|
280
|
+
|
|
281
|
+
def _user_key(self, user: Ts3OnlineUser) -> str:
|
|
282
|
+
if user.unique_id:
|
|
283
|
+
return f"uid:{user.unique_id}"
|
|
284
|
+
if user.database_id:
|
|
285
|
+
return f"db:{user.database_id}"
|
|
286
|
+
if user.client_id:
|
|
287
|
+
return f"clid:{user.client_id}"
|
|
288
|
+
return f"name:{user.nickname}"
|
|
289
|
+
|
|
290
|
+
def _format_now(self) -> str:
|
|
291
|
+
return self._now_factory().strftime("%Y-%m-%d %H:%M:%S")
|
|
292
|
+
|
|
293
|
+
def _format_duration(self, seconds: int) -> str:
|
|
294
|
+
total = max(0, seconds)
|
|
295
|
+
hours, remainder = divmod(total, 3600)
|
|
296
|
+
minutes, secs = divmod(remainder, 60)
|
|
297
|
+
if hours:
|
|
298
|
+
return f"{hours}小时{minutes}分{secs}秒"
|
|
299
|
+
if minutes:
|
|
300
|
+
return f"{minutes}分{secs}秒"
|
|
301
|
+
return f"{secs}秒"
|
|
302
|
+
|
|
303
|
+
def _format_online_duration(self, snapshot: TrackedClientSnapshot) -> str:
|
|
304
|
+
if snapshot.first_seen_at:
|
|
305
|
+
try:
|
|
306
|
+
started_at = datetime.strptime(snapshot.first_seen_at, "%Y-%m-%d %H:%M:%S")
|
|
307
|
+
except ValueError:
|
|
308
|
+
started_at = None
|
|
309
|
+
else:
|
|
310
|
+
seconds = int((self._now_factory() - started_at).total_seconds())
|
|
311
|
+
return self._format_duration(seconds)
|
|
312
|
+
return self._format_duration(snapshot.connected_duration_seconds)
|
|
313
|
+
|
|
314
|
+
def _format_online_list(self, status: Ts3ServerStatus) -> str:
|
|
315
|
+
if not status.users:
|
|
316
|
+
return "暂无在线用户"
|
|
317
|
+
return ", ".join(user.nickname for user in status.users)
|
|
318
|
+
|
|
319
|
+
def get_online_duration_seconds(self, user: Ts3OnlineUser) -> int | None:
|
|
320
|
+
key = self._user_key(user)
|
|
321
|
+
snapshot = self._snapshot.get(key)
|
|
322
|
+
if snapshot is not None:
|
|
323
|
+
if snapshot.first_seen_at:
|
|
324
|
+
try:
|
|
325
|
+
started_at = datetime.strptime(
|
|
326
|
+
snapshot.first_seen_at, "%Y-%m-%d %H:%M:%S"
|
|
327
|
+
)
|
|
328
|
+
except ValueError:
|
|
329
|
+
pass
|
|
330
|
+
else:
|
|
331
|
+
return max(0, int((self._now_factory() - started_at).total_seconds()))
|
|
332
|
+
if snapshot.connected_duration_seconds > 0:
|
|
333
|
+
return snapshot.connected_duration_seconds
|
|
334
|
+
if user.connected_duration_seconds > 0:
|
|
335
|
+
return user.connected_duration_seconds
|
|
336
|
+
return None
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from nonebot import logger
|
|
7
|
+
|
|
8
|
+
from .config import Ts3TrackerSettings
|
|
9
|
+
from .models import Ts3OnlineUser, Ts3ServerStatus
|
|
10
|
+
from .query import Ts3QueryClient, Ts3QueryError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
QueryClientFactory = Callable[[], Ts3QueryClient]
|
|
14
|
+
DurationProvider = Callable[[Ts3OnlineUser], int | None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Ts3TrackerService:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
settings: Ts3TrackerSettings,
|
|
21
|
+
client_factory: QueryClientFactory | None = None,
|
|
22
|
+
duration_provider: DurationProvider | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.settings = settings
|
|
25
|
+
self._client_factory = client_factory
|
|
26
|
+
self._duration_provider = duration_provider
|
|
27
|
+
|
|
28
|
+
async def build_server_message(self) -> str:
|
|
29
|
+
missing_fields = self.get_missing_required_fields()
|
|
30
|
+
if missing_fields:
|
|
31
|
+
return "TS3 配置不完整,请先填写:" + "、".join(missing_fields)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
status = await self.fetch_status()
|
|
35
|
+
except Ts3QueryError as exc:
|
|
36
|
+
logger.warning("TS3 query failed: {}", exc)
|
|
37
|
+
return f"TS3 查询失败:{exc}"
|
|
38
|
+
except Exception as exc: # pragma: no cover - defensive guard
|
|
39
|
+
logger.exception("Unexpected TS3 query error: {}", exc)
|
|
40
|
+
return "TS3 查询失败:发生了未预期错误,请查看 NoneBot 日志。"
|
|
41
|
+
|
|
42
|
+
return self.format_server_status(status)
|
|
43
|
+
|
|
44
|
+
async def fetch_status(self) -> Ts3ServerStatus:
|
|
45
|
+
return await self._build_client().fetch_status()
|
|
46
|
+
|
|
47
|
+
def get_missing_required_fields(self) -> list[str]:
|
|
48
|
+
missing: list[str] = []
|
|
49
|
+
if not self.settings.server_host:
|
|
50
|
+
missing.append("服务器地址")
|
|
51
|
+
if self.settings.server_port <= 0:
|
|
52
|
+
missing.append("服务器端口")
|
|
53
|
+
if not self.settings.serverquery_username:
|
|
54
|
+
missing.append("ServerQuery 账号")
|
|
55
|
+
if not self.settings.serverquery_password:
|
|
56
|
+
missing.append("ServerQuery 密码")
|
|
57
|
+
return missing
|
|
58
|
+
|
|
59
|
+
def format_server_status(self, status: Ts3ServerStatus) -> str:
|
|
60
|
+
lines = [
|
|
61
|
+
f"服务器地址:{status.server_host}:{status.server_port}",
|
|
62
|
+
f"服务器端口:{status.server_port}",
|
|
63
|
+
f"服务器名称:{status.server_name or '-'}",
|
|
64
|
+
"服务器频道:",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
for channel_name, users in self.group_users_by_channel(status):
|
|
68
|
+
display_name = channel_name or "未命名频道"
|
|
69
|
+
if users:
|
|
70
|
+
lines.append(f"{display_name}: {', '.join(users)}")
|
|
71
|
+
else:
|
|
72
|
+
lines.append(display_name)
|
|
73
|
+
|
|
74
|
+
return "\n".join(lines)
|
|
75
|
+
|
|
76
|
+
def group_users_by_channel(
|
|
77
|
+
self, status: Ts3ServerStatus
|
|
78
|
+
) -> list[tuple[str, list[str]]]:
|
|
79
|
+
grouped: dict[str, dict[str, object]] = {}
|
|
80
|
+
for channel_id, channel_name in status.channels:
|
|
81
|
+
grouped.setdefault(channel_id, {"name": channel_name, "users": []})
|
|
82
|
+
|
|
83
|
+
for user in status.users:
|
|
84
|
+
channel_id = user.channel_id or "__unknown__"
|
|
85
|
+
channel_entry = grouped.setdefault(
|
|
86
|
+
channel_id,
|
|
87
|
+
{"name": user.channel_name or "未命名频道", "users": []},
|
|
88
|
+
)
|
|
89
|
+
users = channel_entry["users"]
|
|
90
|
+
assert isinstance(users, list)
|
|
91
|
+
users.append(self._format_user_display(user))
|
|
92
|
+
|
|
93
|
+
ordered_groups: list[tuple[str, list[str]]] = []
|
|
94
|
+
for channel_id, _ in status.channels:
|
|
95
|
+
channel_entry = grouped.pop(channel_id, None)
|
|
96
|
+
if channel_entry is None:
|
|
97
|
+
continue
|
|
98
|
+
ordered_groups.append(
|
|
99
|
+
(str(channel_entry["name"]), list(channel_entry["users"]))
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
for channel_entry in grouped.values():
|
|
103
|
+
ordered_groups.append(
|
|
104
|
+
(str(channel_entry["name"]), list(channel_entry["users"]))
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return ordered_groups
|
|
108
|
+
|
|
109
|
+
def _build_client(self) -> Ts3QueryClient:
|
|
110
|
+
if self._client_factory is not None:
|
|
111
|
+
return self._client_factory()
|
|
112
|
+
|
|
113
|
+
return Ts3QueryClient(
|
|
114
|
+
host=self.settings.server_host,
|
|
115
|
+
server_port=self.settings.server_port,
|
|
116
|
+
query_port=self.settings.serverquery_port,
|
|
117
|
+
username=self.settings.serverquery_username,
|
|
118
|
+
password=self.settings.serverquery_password,
|
|
119
|
+
timeout=self.settings.query_timeout_seconds,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _format_user_display(self, user: Ts3OnlineUser) -> str:
|
|
123
|
+
duration = self._get_user_duration_seconds(user)
|
|
124
|
+
if duration is None:
|
|
125
|
+
return user.nickname
|
|
126
|
+
return f"{user.nickname}({self._format_duration(duration)})"
|
|
127
|
+
|
|
128
|
+
def _get_user_duration_seconds(self, user: Ts3OnlineUser) -> int | None:
|
|
129
|
+
if self._duration_provider is not None:
|
|
130
|
+
duration = self._duration_provider(user)
|
|
131
|
+
if duration is not None:
|
|
132
|
+
return max(0, duration)
|
|
133
|
+
if user.connected_duration_seconds > 0:
|
|
134
|
+
return user.connected_duration_seconds
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def _format_duration(self, seconds: int) -> str:
|
|
138
|
+
total = max(0, seconds)
|
|
139
|
+
hours, remainder = divmod(total, 3600)
|
|
140
|
+
minutes, secs = divmod(remainder, 60)
|
|
141
|
+
if hours:
|
|
142
|
+
return f"{hours}小时{minutes}分{secs}秒"
|
|
143
|
+
if minutes:
|
|
144
|
+
return f"{minutes}分{secs}秒"
|
|
145
|
+
return f"{secs}秒"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class TrackedClientSnapshot:
|
|
10
|
+
nickname: str
|
|
11
|
+
unique_id: str
|
|
12
|
+
channel_id: str
|
|
13
|
+
channel_name: str
|
|
14
|
+
connected_duration_seconds: int
|
|
15
|
+
away: bool
|
|
16
|
+
first_seen_at: str = ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SnapshotStore:
|
|
20
|
+
def __init__(self, data_file: Path) -> None:
|
|
21
|
+
self._data_file = data_file
|
|
22
|
+
|
|
23
|
+
def load(self) -> dict[str, TrackedClientSnapshot]:
|
|
24
|
+
if not self._data_file.exists():
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
raw = json.loads(self._data_file.read_text(encoding="utf-8"))
|
|
28
|
+
if not isinstance(raw, dict):
|
|
29
|
+
raise ValueError("snapshot file root node must be an object")
|
|
30
|
+
|
|
31
|
+
snapshots: dict[str, TrackedClientSnapshot] = {}
|
|
32
|
+
for key, value in raw.items():
|
|
33
|
+
if not isinstance(key, str) or not isinstance(value, dict):
|
|
34
|
+
continue
|
|
35
|
+
snapshots[key] = TrackedClientSnapshot(**value)
|
|
36
|
+
return snapshots
|
|
37
|
+
|
|
38
|
+
def save(self, snapshots: dict[str, TrackedClientSnapshot]) -> None:
|
|
39
|
+
self._data_file.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
payload = {
|
|
41
|
+
key: asdict(snapshot)
|
|
42
|
+
for key, snapshot in sorted(snapshots.items(), key=lambda item: item[0])
|
|
43
|
+
}
|
|
44
|
+
self._data_file.write_text(
|
|
45
|
+
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True),
|
|
46
|
+
encoding="utf-8",
|
|
47
|
+
)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nonebot-plugin-ts3-tracker
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: TeamSpeak 3 query and join/leave notification plugin for NoneBot2
|
|
5
|
+
Keywords: nonebot,nonebot2,ts3,teamspeak3,serverquery,onebot
|
|
6
|
+
Author: neri
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Dist: nonebot2>=2.4.4,<3.0.0
|
|
9
|
+
Requires-Dist: nonebot-adapter-onebot>=2.4.0,<3.0.0
|
|
10
|
+
Requires-Dist: nonebot-plugin-localstore>=0.7.4,<1.0.0
|
|
11
|
+
Requires-Dist: pydantic>=2.0.0,<3.0.0
|
|
12
|
+
Requires-Python: >=3.10, <4.0
|
|
13
|
+
Project-URL: Homepage, https://github.com/moeneri/nonebot-plugin-ts3-tracker
|
|
14
|
+
Project-URL: Repository, https://github.com/moeneri/nonebot-plugin-ts3-tracker
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# nonebot-plugin-ts3-tracker
|
|
18
|
+
|
|
19
|
+
基于 NoneBot2 的 TeamSpeak 3 查询与进服/退服通知插件。
|
|
20
|
+
|
|
21
|
+
本插件提供以下查询命令:
|
|
22
|
+
|
|
23
|
+
- `上号`
|
|
24
|
+
- `ts`
|
|
25
|
+
- `tsinfo`
|
|
26
|
+
|
|
27
|
+
并补充了适合长期运行的能力:
|
|
28
|
+
|
|
29
|
+
- TS3 进服通知
|
|
30
|
+
- TS3 退服通知
|
|
31
|
+
- 5 秒轮询监控
|
|
32
|
+
- 在线快照持久化
|
|
33
|
+
- 重启后静默同步,避免历史误报
|
|
34
|
+
- 频道内玩家在线时长显示
|
|
35
|
+
- 群聊 / 私聊主动推送
|
|
36
|
+
- 群白名单模式
|
|
37
|
+
- 中文日志输出
|
|
38
|
+
|
|
39
|
+
## 安装
|
|
40
|
+
|
|
41
|
+
1. 安装插件:
|
|
42
|
+
`nb plugin install nonebot-plugin-ts3-tracker`
|
|
43
|
+
2. 确保已安装本插件依赖的 `nonebot-plugin-localstore`
|
|
44
|
+
3. 在机器人项目中加载插件 `nonebot_plugin_ts3_tracker`
|
|
45
|
+
4. 配置环境变量
|
|
46
|
+
|
|
47
|
+
## 基础配置
|
|
48
|
+
|
|
49
|
+
插件使用以下环境变量:
|
|
50
|
+
|
|
51
|
+
```env
|
|
52
|
+
TS3_TRACKER__SERVER_HOST=127.0.0.1
|
|
53
|
+
TS3_TRACKER__SERVER_PORT=9987
|
|
54
|
+
TS3_TRACKER__SERVERQUERY_PORT=10011
|
|
55
|
+
TS3_TRACKER__SERVERQUERY_USERNAME=your-serverquery-username
|
|
56
|
+
TS3_TRACKER__SERVERQUERY_PASSWORD=your-password
|
|
57
|
+
TS3_TRACKER__DEBUG=false
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 配置
|
|
61
|
+
|
|
62
|
+
```env
|
|
63
|
+
HOST=127.0.0.1
|
|
64
|
+
PORT=8080
|
|
65
|
+
|
|
66
|
+
TS3_TRACKER__SERVER_HOST=127.0.0.1
|
|
67
|
+
TS3_TRACKER__SERVER_PORT=9987
|
|
68
|
+
TS3_TRACKER__SERVERQUERY_PORT=10011
|
|
69
|
+
TS3_TRACKER__SERVERQUERY_USERNAME=your-serverquery-username
|
|
70
|
+
TS3_TRACKER__SERVERQUERY_PASSWORD=your-password
|
|
71
|
+
TS3_TRACKER__DEBUG=false
|
|
72
|
+
TS3_TRACKER__COMMAND_PRIORITY=10
|
|
73
|
+
TS3_TRACKER__QUERY_TIMEOUT_SECONDS=10
|
|
74
|
+
|
|
75
|
+
TS3_TRACKER__NOTIFICATION_ENABLED=true
|
|
76
|
+
TS3_TRACKER__NOTIFY_TARGET_GROUPS=123456789
|
|
77
|
+
TS3_TRACKER__NOTIFY_TARGET_USERS=
|
|
78
|
+
TS3_TRACKER__NOTIFY_BOT_ID=
|
|
79
|
+
|
|
80
|
+
TS3_TRACKER__GROUP_WHITELIST_ENABLED=false
|
|
81
|
+
TS3_TRACKER__GROUP_WHITELIST_GROUPS=
|
|
82
|
+
|
|
83
|
+
TS3_TRACKER__POLL_INTERVAL_SECONDS=5
|
|
84
|
+
TS3_TRACKER__STARTUP_SILENT=true
|
|
85
|
+
TS3_TRACKER__DATA_DIR=data/ts3_tracker
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
配置说明:
|
|
89
|
+
|
|
90
|
+
- `TS3_TRACKER__SERVER_HOST`:TS3 服务器地址
|
|
91
|
+
- `TS3_TRACKER__SERVER_PORT`:TS3 语音端口,通常为 `9987`
|
|
92
|
+
- `TS3_TRACKER__SERVERQUERY_PORT`:TS3 ServerQuery 端口,通常为 `10011`
|
|
93
|
+
- `TS3_TRACKER__SERVERQUERY_USERNAME`:ServerQuery 用户名
|
|
94
|
+
- `TS3_TRACKER__SERVERQUERY_PASSWORD`:ServerQuery 密码
|
|
95
|
+
- `TS3_TRACKER__DEBUG`:是否输出调试日志
|
|
96
|
+
- `TS3_TRACKER__COMMAND_PRIORITY`:命令优先级
|
|
97
|
+
- `TS3_TRACKER__QUERY_TIMEOUT_SECONDS`:单次查询超时秒数
|
|
98
|
+
- `TS3_TRACKER__NOTIFICATION_ENABLED`:是否启用进服/退服通知
|
|
99
|
+
- `TS3_TRACKER__NOTIFY_TARGET_GROUPS`:接收通知的群号,支持逗号、分号、换行分隔
|
|
100
|
+
- `TS3_TRACKER__NOTIFY_TARGET_USERS`:接收通知的私聊 QQ,支持逗号、分号、换行分隔
|
|
101
|
+
- `TS3_TRACKER__NOTIFY_BOT_ID`:指定发送主动通知的 OneBot v11 Bot ID
|
|
102
|
+
- `TS3_TRACKER__GROUP_WHITELIST_ENABLED`:是否开启群白名单模式
|
|
103
|
+
- `TS3_TRACKER__GROUP_WHITELIST_GROUPS`:允许使用群命令查询,且允许接收群通知的白名单群号
|
|
104
|
+
- `TS3_TRACKER__POLL_INTERVAL_SECONDS`:轮询间隔,默认 `5`
|
|
105
|
+
- `TS3_TRACKER__STARTUP_SILENT`:启动时只同步快照,不立刻发送历史在线通知
|
|
106
|
+
- `TS3_TRACKER__DATA_DIR`:自定义快照持久化目录;不填写时使用 `nonebot-plugin-localstore` 的插件数据目录
|
|
107
|
+
|
|
108
|
+
## 群白名单模式
|
|
109
|
+
|
|
110
|
+
默认情况下:
|
|
111
|
+
|
|
112
|
+
- 所有群聊都可以使用 `上号`、`ts`、`tsinfo`
|
|
113
|
+
- 所有私聊都可以使用查询命令
|
|
114
|
+
- 通知只会发给 `TS3_TRACKER__NOTIFY_TARGET_GROUPS` 和 `TS3_TRACKER__NOTIFY_TARGET_USERS`
|
|
115
|
+
|
|
116
|
+
当开启白名单模式后:
|
|
117
|
+
|
|
118
|
+
```env
|
|
119
|
+
TS3_TRACKER__GROUP_WHITELIST_ENABLED=true
|
|
120
|
+
TS3_TRACKER__GROUP_WHITELIST_GROUPS=123456789
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
效果如下:
|
|
124
|
+
|
|
125
|
+
- 只有白名单群可以使用群聊查询命令
|
|
126
|
+
- 私聊仍然可以查询
|
|
127
|
+
- 群通知只会发到白名单内,且同时存在于 `TS3_TRACKER__NOTIFY_TARGET_GROUPS` 的群
|
|
128
|
+
|
|
129
|
+
## 命令
|
|
130
|
+
|
|
131
|
+
群聊或私聊发送以下任一命令:
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
上号
|
|
135
|
+
ts
|
|
136
|
+
tsinfo
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## 查询返回示例
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
服务器地址:127.0.0.1:9987
|
|
143
|
+
服务器端口:9987
|
|
144
|
+
服务器名称:迷你世界高手大会
|
|
145
|
+
服务器频道:
|
|
146
|
+
APEX: neri(20秒)
|
|
147
|
+
原神
|
|
148
|
+
守望先锋-归西
|
|
149
|
+
穿越火线
|
|
150
|
+
永雏塔菲
|
|
151
|
+
高能英雄
|
|
152
|
+
三国杀
|
|
153
|
+
迷你世界
|
|
154
|
+
王者荣耀
|
|
155
|
+
三角洲行动
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## 通知示例
|
|
159
|
+
|
|
160
|
+
进服通知:
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
让我看看是谁还没上号 👀
|
|
164
|
+
🧾 昵称:neri
|
|
165
|
+
🟢 上线时间:2026-03-16 00:33:19
|
|
166
|
+
📣 neri 进入了 TS 服务器
|
|
167
|
+
👥 当前在线人数:3
|
|
168
|
+
📜 在线列表:neri, koishi
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
退服通知:
|
|
172
|
+
|
|
173
|
+
```text
|
|
174
|
+
📤 用户下线通知
|
|
175
|
+
🧾 昵称:neri
|
|
176
|
+
🟢 上线时间:2026-03-16 00:32:51
|
|
177
|
+
🔴 下线时间:2026-03-16 01:31:37
|
|
178
|
+
⏱️ 在线时长:58分46秒
|
|
179
|
+
👥 当前在线人数:2
|
|
180
|
+
📜 在线列表:KirA, Cirno
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## 日志示例
|
|
184
|
+
|
|
185
|
+
```text
|
|
186
|
+
群号 123456789 查询了服务器信息。
|
|
187
|
+
neri 进入了服务器。
|
|
188
|
+
neri 退出了服务器。
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## 数据持久化
|
|
192
|
+
|
|
193
|
+
插件当前不是通过数据库持久化。
|
|
194
|
+
|
|
195
|
+
它使用本地 JSON 快照文件记录在线状态。
|
|
196
|
+
|
|
197
|
+
默认情况下,文件通过 `nonebot-plugin-localstore` 保存到插件专属数据目录。
|
|
198
|
+
|
|
199
|
+
如果你手动设置了 `TS3_TRACKER__DATA_DIR`,则会改为保存到你指定目录下的:
|
|
200
|
+
|
|
201
|
+
```text
|
|
202
|
+
<TS3_TRACKER__DATA_DIR>/snapshot.json
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
快照中主要保存:
|
|
206
|
+
|
|
207
|
+
- 用户唯一标识
|
|
208
|
+
- 所在频道
|
|
209
|
+
- 首次上线时间
|
|
210
|
+
- 已连接时长
|
|
211
|
+
- 离开检测所需的对比状态
|
|
212
|
+
|
|
213
|
+
## 验证
|
|
214
|
+
|
|
215
|
+
运行单元测试:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
pytest
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
实时验证 TS3 查询:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
python scripts/verify_live.py
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## NoneBot2 规范确认
|
|
228
|
+
|
|
229
|
+
已按本地 `nonebot2-master` 文档核对以下要求:
|
|
230
|
+
|
|
231
|
+
- 使用 `PluginMetadata` 声明插件元信息
|
|
232
|
+
- 明确 `config=Config`
|
|
233
|
+
- 明确 `supported_adapters={"nonebot.adapters.onebot.v11"}`
|
|
234
|
+
- 使用标准 `pyproject.toml` 打包结构
|
|
235
|
+
- 根目录包含 `README.md`、`LICENSE`、测试目录与验证脚本
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
nonebot_plugin_ts3_tracker/__init__.py,sha256=ygX0v3X6RO_9Wr7LkDvmmDE9D3JM4LOZPfVKrX9jUK8,2615
|
|
2
|
+
nonebot_plugin_ts3_tracker/config.py,sha256=QKR5XR2pQMs2o4Qh9jCApMHUKi0Jo_KvSv22EUDPKPw,2347
|
|
3
|
+
nonebot_plugin_ts3_tracker/models.py,sha256=6PoEZEQ55TAhq282rBR9YzhPKSkgzug5eWDmtBF2ERs,505
|
|
4
|
+
nonebot_plugin_ts3_tracker/query.py,sha256=-k20ATg1PRgKuI1NbfjELiQMOCZHINKOP3xDySA754M,8791
|
|
5
|
+
nonebot_plugin_ts3_tracker/runtime.py,sha256=lS66-GYEf11is6qQh7qLRckUqsErZYGPpyxczRj8KM0,13100
|
|
6
|
+
nonebot_plugin_ts3_tracker/service.py,sha256=axWCdt1b3igvmkXq1LlszIub8wXZF6PEk3kZrWNL2zI,5419
|
|
7
|
+
nonebot_plugin_ts3_tracker/storage.py,sha256=6tSz4K21vhB8HR_xhh6tl7-M_lLcemrnwQiFwMdgfb0,1461
|
|
8
|
+
nonebot_plugin_ts3_tracker-1.0.0.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
|
|
9
|
+
nonebot_plugin_ts3_tracker-1.0.0.dist-info/METADATA,sha256=8SCXjcffkdTpI4C32cpR4dh_GHdyZS4GfZqPLlFs_LY,6188
|
|
10
|
+
nonebot_plugin_ts3_tracker-1.0.0.dist-info/RECORD,,
|