automas-plugin-kill-process 0.1.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.
- automas_plugin_kill_process-0.1.0.dist-info/METADATA +36 -0
- automas_plugin_kill_process-0.1.0.dist-info/RECORD +9 -0
- automas_plugin_kill_process-0.1.0.dist-info/WHEEL +5 -0
- automas_plugin_kill_process-0.1.0.dist-info/entry_points.txt +2 -0
- automas_plugin_kill_process-0.1.0.dist-info/licenses/LICENSE +21 -0
- automas_plugin_kill_process-0.1.0.dist-info/top_level.txt +1 -0
- kill_process/__init__.py +6 -0
- kill_process/plugin.py +301 -0
- kill_process/schema.py +30 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: automas_plugin_kill_process
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 简单的关闭程序插件
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# automas_kill_process
|
|
12
|
+
|
|
13
|
+
[](https://pypi.org/project/automas_plugin_kill_process/)
|
|
14
|
+
|
|
15
|
+
简单的关闭程序插件
|
|
16
|
+
|
|
17
|
+
## 功能
|
|
18
|
+
|
|
19
|
+
- 监听全局 script.exit 事件。
|
|
20
|
+
- 当事件中的脚本名匹配配置规则里的 script_name 时,自动执行 taskkill 终止对应 process_name。
|
|
21
|
+
- 支持多条规则,规则表可无限新增行。
|
|
22
|
+
|
|
23
|
+
## 配置
|
|
24
|
+
|
|
25
|
+
插件配置提供两个字段:
|
|
26
|
+
|
|
27
|
+
- rules:表格规则。
|
|
28
|
+
- script_name:脚本配置里的 Info.Name,例如 崩铁-三月七。
|
|
29
|
+
- process_name:要终止的进程名,例如 StarRail.exe。
|
|
30
|
+
- kill_timeout_seconds:单次 taskkill 超时秒数。
|
|
31
|
+
|
|
32
|
+
## 可靠性说明
|
|
33
|
+
|
|
34
|
+
- 插件在收到事件后使用后台协程执行 taskkill,不阻塞事件总线主流程。
|
|
35
|
+
- 每次 taskkill 都有超时保护,防止卡死。
|
|
36
|
+
- 插件停止时会取消并清理后台任务,避免残留协程影响主程序。
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
automas_plugin_kill_process-0.1.0.dist-info/licenses/LICENSE,sha256=JI9rFgvIWeGm4kUReYvXzroyDOpOGeZ09ULkYvKfYJw,1084
|
|
2
|
+
kill_process/__init__.py,sha256=nYWxGLIy8CFGcRhjKDCgGZqcnNHxNCHIRNWRYH4RKUs,124
|
|
3
|
+
kill_process/plugin.py,sha256=6qYgKJXQ8PW1KM2MiLembxymsX29pPt5iv1n9k2B5BE,10512
|
|
4
|
+
kill_process/schema.py,sha256=d_Vr5zzkLfw0or3AnYEFFlkq9O-XirKcEjtemBlyKhs,849
|
|
5
|
+
automas_plugin_kill_process-0.1.0.dist-info/METADATA,sha256=u2oj5hvU8cgVpP4ISSxBEF4CEq8i3eB3yY3GHfnVJSM,1245
|
|
6
|
+
automas_plugin_kill_process-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
automas_plugin_kill_process-0.1.0.dist-info/entry_points.txt,sha256=nXUVOn88HYidR6on0sOMl3BfEFBwvRef39z5GE5BN9A,61
|
|
8
|
+
automas_plugin_kill_process-0.1.0.dist-info/top_level.txt,sha256=BTd1-V3miwtIS5RmqOfNogFBPOKA6WZH-ApiyhDbrNI,13
|
|
9
|
+
automas_plugin_kill_process-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alirea
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kill_process
|
kill_process/__init__.py
ADDED
kill_process/plugin.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import locale
|
|
6
|
+
import os
|
|
7
|
+
from collections import deque
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Iterable
|
|
10
|
+
|
|
11
|
+
from app.core.plugins import on_event
|
|
12
|
+
|
|
13
|
+
from .schema import Config
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from app.core.plugins.context import PluginContext
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class KillRule:
|
|
21
|
+
script_name: str
|
|
22
|
+
process_name: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Plugin:
|
|
26
|
+
_RECENT_EVENT_LIMIT = 512
|
|
27
|
+
|
|
28
|
+
def __init__(self, ctx: "PluginContext") -> None:
|
|
29
|
+
self.ctx = ctx
|
|
30
|
+
self._rules: list[KillRule] = []
|
|
31
|
+
self._kill_timeout_seconds: int = 8
|
|
32
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
33
|
+
self._recent_event_keys: set[tuple[str, str, str]] = set()
|
|
34
|
+
self._recent_event_queue: deque[tuple[str, str, str]] = deque()
|
|
35
|
+
|
|
36
|
+
async def on_start(self) -> None:
|
|
37
|
+
raw_config = (
|
|
38
|
+
self.ctx.config.to_dict()
|
|
39
|
+
if hasattr(self.ctx.config, "to_dict")
|
|
40
|
+
else dict(self.ctx.config)
|
|
41
|
+
)
|
|
42
|
+
typed_config = Config.model_validate(raw_config)
|
|
43
|
+
self._kill_timeout_seconds = max(1, int(typed_config.kill_timeout_seconds))
|
|
44
|
+
self._rules = self._normalize_rules(typed_config.rules)
|
|
45
|
+
|
|
46
|
+
self.ctx.logger.info(
|
|
47
|
+
f"[{self.ctx.plugin_name}] 插件启动,规则数={len(self._rules)},超时={self._kill_timeout_seconds}s"
|
|
48
|
+
)
|
|
49
|
+
if os.name != "nt":
|
|
50
|
+
self.ctx.logger.warning(
|
|
51
|
+
f"[{self.ctx.plugin_name}] 当前系统不是 Windows,taskkill 不可用"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def on_stop(self, reason: str) -> None:
|
|
55
|
+
await self._shutdown_background_tasks()
|
|
56
|
+
self.ctx.logger.info(
|
|
57
|
+
f"[{self.ctx.plugin_name}] 插件停止, reason={reason}, 已清理后台任务"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@on_event("script.exit", scope="global")
|
|
61
|
+
async def on_script_exit(self, payload: Any, ctx: "PluginContext") -> None:
|
|
62
|
+
_ = ctx
|
|
63
|
+
try:
|
|
64
|
+
if not isinstance(payload, dict):
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
script_name = str(payload.get("script_name") or "").strip()
|
|
68
|
+
if not script_name:
|
|
69
|
+
data = payload.get("data")
|
|
70
|
+
if isinstance(data, dict):
|
|
71
|
+
script_name = str(data.get("script_name") or "").strip()
|
|
72
|
+
|
|
73
|
+
if not script_name:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
if not self._rules:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
task_id = str(payload.get("task_id") or "").strip() or "-"
|
|
80
|
+
script_id = str(payload.get("script_id") or "").strip() or script_name
|
|
81
|
+
|
|
82
|
+
matched_processes = {
|
|
83
|
+
rule.process_name
|
|
84
|
+
for rule in self._rules
|
|
85
|
+
if rule.script_name == script_name
|
|
86
|
+
}
|
|
87
|
+
if not matched_processes:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
for process_name in matched_processes:
|
|
91
|
+
event_key = (task_id, script_id, process_name)
|
|
92
|
+
if self._is_duplicate_event(event_key):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
self._remember_event(event_key)
|
|
96
|
+
self._spawn_background_task(
|
|
97
|
+
self._kill_process_for_script(
|
|
98
|
+
script_name=script_name,
|
|
99
|
+
process_name=process_name,
|
|
100
|
+
task_id=task_id,
|
|
101
|
+
script_id=script_id,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
self.ctx.logger.warning(
|
|
106
|
+
f"[{self.ctx.plugin_name}] 处理 script.exit 事件失败: {type(e).__name__}: {e}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _normalize_rules(self, rows: Iterable[Any]) -> list[KillRule]:
|
|
110
|
+
rules: list[KillRule] = []
|
|
111
|
+
seen: set[tuple[str, str]] = set()
|
|
112
|
+
|
|
113
|
+
for row in rows:
|
|
114
|
+
current = row
|
|
115
|
+
if hasattr(current, "model_dump") and callable(current.model_dump):
|
|
116
|
+
current = current.model_dump()
|
|
117
|
+
|
|
118
|
+
if not isinstance(current, dict):
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
script_name = str(current.get("script_name") or "").strip()
|
|
122
|
+
process_name = str(current.get("process_name") or "").strip()
|
|
123
|
+
if not script_name or not process_name:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
key = (script_name, process_name)
|
|
127
|
+
if key in seen:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
seen.add(key)
|
|
131
|
+
rules.append(KillRule(script_name=script_name, process_name=process_name))
|
|
132
|
+
|
|
133
|
+
return rules
|
|
134
|
+
|
|
135
|
+
def _is_duplicate_event(self, event_key: tuple[str, str, str]) -> bool:
|
|
136
|
+
return event_key in self._recent_event_keys
|
|
137
|
+
|
|
138
|
+
def _remember_event(self, event_key: tuple[str, str, str]) -> None:
|
|
139
|
+
self._recent_event_keys.add(event_key)
|
|
140
|
+
self._recent_event_queue.append(event_key)
|
|
141
|
+
|
|
142
|
+
while len(self._recent_event_queue) > self._RECENT_EVENT_LIMIT:
|
|
143
|
+
old_key = self._recent_event_queue.popleft()
|
|
144
|
+
self._recent_event_keys.discard(old_key)
|
|
145
|
+
|
|
146
|
+
def _spawn_background_task(self, coroutine: Any) -> None:
|
|
147
|
+
task = asyncio.create_task(coroutine)
|
|
148
|
+
self._background_tasks.add(task)
|
|
149
|
+
|
|
150
|
+
def _cleanup(done_task: asyncio.Task[Any]) -> None:
|
|
151
|
+
self._background_tasks.discard(done_task)
|
|
152
|
+
try:
|
|
153
|
+
exception = done_task.exception()
|
|
154
|
+
except asyncio.CancelledError:
|
|
155
|
+
return
|
|
156
|
+
except Exception as e:
|
|
157
|
+
self.ctx.logger.warning(
|
|
158
|
+
f"[{self.ctx.plugin_name}] 后台任务状态读取失败: {type(e).__name__}: {e}"
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if exception is not None:
|
|
163
|
+
self.ctx.logger.warning(
|
|
164
|
+
f"[{self.ctx.plugin_name}] 后台任务异常: {type(exception).__name__}: {exception}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
task.add_done_callback(_cleanup)
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _decode_taskkill_output(raw: bytes | None) -> str:
|
|
171
|
+
if not raw:
|
|
172
|
+
return ""
|
|
173
|
+
|
|
174
|
+
preferred = locale.getpreferredencoding(False)
|
|
175
|
+
candidates = ["utf-8", "gbk", "cp936", preferred]
|
|
176
|
+
tried: set[str] = set()
|
|
177
|
+
|
|
178
|
+
for encoding in candidates:
|
|
179
|
+
if not encoding:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
normalized = encoding.lower()
|
|
183
|
+
if normalized in tried:
|
|
184
|
+
continue
|
|
185
|
+
tried.add(normalized)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
text = raw.decode(encoding).strip()
|
|
189
|
+
except UnicodeDecodeError:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
if text:
|
|
193
|
+
return text
|
|
194
|
+
|
|
195
|
+
return raw.decode("utf-8", errors="replace").strip()
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _is_process_not_found_error(return_code: int, detail: str) -> bool:
|
|
199
|
+
if return_code == 128:
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
detail_lower = detail.lower()
|
|
203
|
+
markers = (
|
|
204
|
+
"没有找到",
|
|
205
|
+
"未找到",
|
|
206
|
+
"找不到",
|
|
207
|
+
"not found",
|
|
208
|
+
"could not find",
|
|
209
|
+
"no running instance",
|
|
210
|
+
"unknown process",
|
|
211
|
+
)
|
|
212
|
+
return any(marker in detail or marker in detail_lower for marker in markers)
|
|
213
|
+
|
|
214
|
+
async def _shutdown_background_tasks(self) -> None:
|
|
215
|
+
if not self._background_tasks:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
tasks = list(self._background_tasks)
|
|
219
|
+
for task in tasks:
|
|
220
|
+
task.cancel()
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=2)
|
|
224
|
+
except asyncio.TimeoutError:
|
|
225
|
+
self.ctx.logger.warning(
|
|
226
|
+
f"[{self.ctx.plugin_name}] 关闭时仍有后台任务未在 2 秒内退出"
|
|
227
|
+
)
|
|
228
|
+
finally:
|
|
229
|
+
self._background_tasks.clear()
|
|
230
|
+
|
|
231
|
+
async def _kill_process_for_script(
|
|
232
|
+
self,
|
|
233
|
+
*,
|
|
234
|
+
script_name: str,
|
|
235
|
+
process_name: str,
|
|
236
|
+
task_id: str,
|
|
237
|
+
script_id: str,
|
|
238
|
+
) -> None:
|
|
239
|
+
if os.name != "nt":
|
|
240
|
+
self.ctx.logger.warning(
|
|
241
|
+
f"[{self.ctx.plugin_name}] 非 Windows 环境,跳过终止进程: {process_name}"
|
|
242
|
+
)
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
command = ["taskkill", "/F", "/IM", process_name, "/T"]
|
|
246
|
+
process = None
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
process = await asyncio.create_subprocess_exec(
|
|
250
|
+
*command,
|
|
251
|
+
stdout=asyncio.subprocess.PIPE,
|
|
252
|
+
stderr=asyncio.subprocess.PIPE,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
stdout, stderr = await asyncio.wait_for(
|
|
256
|
+
process.communicate(),
|
|
257
|
+
timeout=self._kill_timeout_seconds,
|
|
258
|
+
)
|
|
259
|
+
except asyncio.TimeoutError:
|
|
260
|
+
if process is not None:
|
|
261
|
+
with contextlib.suppress(Exception):
|
|
262
|
+
process.kill()
|
|
263
|
+
with contextlib.suppress(Exception):
|
|
264
|
+
await process.communicate()
|
|
265
|
+
self.ctx.logger.warning(
|
|
266
|
+
f"[{self.ctx.plugin_name}] 终止进程超时: script={script_name}, process={process_name}, timeout={self._kill_timeout_seconds}s"
|
|
267
|
+
)
|
|
268
|
+
return
|
|
269
|
+
except asyncio.CancelledError:
|
|
270
|
+
if process is not None:
|
|
271
|
+
with contextlib.suppress(Exception):
|
|
272
|
+
process.kill()
|
|
273
|
+
with contextlib.suppress(Exception):
|
|
274
|
+
await process.communicate()
|
|
275
|
+
raise
|
|
276
|
+
except Exception as e:
|
|
277
|
+
self.ctx.logger.warning(
|
|
278
|
+
f"[{self.ctx.plugin_name}] 调用 taskkill 失败: script={script_name}, process={process_name}, error={type(e).__name__}: {e}"
|
|
279
|
+
)
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
stdout_text = self._decode_taskkill_output(stdout)
|
|
283
|
+
stderr_text = self._decode_taskkill_output(stderr)
|
|
284
|
+
detail = stderr_text or stdout_text or "-"
|
|
285
|
+
if len(detail) > 300:
|
|
286
|
+
detail = f"{detail[:300]}..."
|
|
287
|
+
|
|
288
|
+
return_code = process.returncode if process is not None else -1
|
|
289
|
+
if return_code == 0:
|
|
290
|
+
self.ctx.logger.info(
|
|
291
|
+
f"[{self.ctx.plugin_name}] 已终止进程: script={script_name}, process={process_name}, task_id={task_id}, script_id={script_id}"
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
if self._is_process_not_found_error(return_code, detail):
|
|
295
|
+
self.ctx.logger.warning(
|
|
296
|
+
f"[{self.ctx.plugin_name}] 程序无法关闭,可能程序已关闭或进程名有误: script={script_name}, process={process_name}, code={return_code}"
|
|
297
|
+
)
|
|
298
|
+
else:
|
|
299
|
+
self.ctx.logger.warning(
|
|
300
|
+
f"[{self.ctx.plugin_name}] 终止进程返回非 0: script={script_name}, process={process_name}, code={return_code}, detail={detail}"
|
|
301
|
+
)
|
kill_process/schema.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Config(BaseModel):
|
|
5
|
+
"""关闭进程插件配置模型。"""
|
|
6
|
+
|
|
7
|
+
model_config = ConfigDict(extra="allow")
|
|
8
|
+
|
|
9
|
+
rules: list[dict[str, str]] = Field(
|
|
10
|
+
default_factory=lambda: [{"script_name": "", "process_name": ""}],
|
|
11
|
+
description="规则表:当 script.exit 的脚本名匹配 script_name 时,自动执行 taskkill 终止 process_name",
|
|
12
|
+
json_schema_extra={
|
|
13
|
+
"type": "table",
|
|
14
|
+
"item_type": "object",
|
|
15
|
+
"group": "basic",
|
|
16
|
+
"order": 1,
|
|
17
|
+
},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
kill_timeout_seconds: int = Field(
|
|
21
|
+
default=8,
|
|
22
|
+
ge=1,
|
|
23
|
+
description="单次 taskkill 超时秒数",
|
|
24
|
+
json_schema_extra={
|
|
25
|
+
"group": "advanced",
|
|
26
|
+
"min": 1,
|
|
27
|
+
"step": 1,
|
|
28
|
+
"order": 2,
|
|
29
|
+
},
|
|
30
|
+
)
|