pgwxauto 0.1.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.
- pgwxauto/__init__.py +62 -0
- pgwxauto/_version.py +4 -0
- pgwxauto/ai.py +292 -0
- pgwxauto/client.py +164 -0
- pgwxauto/config.py +34 -0
- pgwxauto/core/__init__.py +26 -0
- pgwxauto/core/accessibility_gate.py +373 -0
- pgwxauto/core/exceptions.py +37 -0
- pgwxauto/core/tray.py +496 -0
- pgwxauto/core/uia_wrapper.py +138 -0
- pgwxauto/core/uiautomation.py +8153 -0
- pgwxauto/core/win32.py +293 -0
- pgwxauto/core/window.py +699 -0
- pgwxauto/features/__init__.py +8 -0
- pgwxauto/features/base.py +47 -0
- pgwxauto/features/chat.py +1402 -0
- pgwxauto/features/groups.py +869 -0
- pgwxauto/features/messaging/__init__.py +35 -0
- pgwxauto/features/messaging/forwarder.py +185 -0
- pgwxauto/features/messaging/listener.py +682 -0
- pgwxauto/features/messaging/processor.py +342 -0
- pgwxauto/utils/__init__.py +15 -0
- pgwxauto/utils/clipboard_utils.py +98 -0
- pgwxauto/utils/logger.py +62 -0
- pgwxauto/utils/markdown_utils.py +142 -0
- pgwxauto-0.1.2.dist-info/METADATA +450 -0
- pgwxauto-0.1.2.dist-info/RECORD +30 -0
- pgwxauto-0.1.2.dist-info/WHEEL +5 -0
- pgwxauto-0.1.2.dist-info/licenses/LICENSE +661 -0
- pgwxauto-0.1.2.dist-info/top_level.txt +1 -0
pgwxauto/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
pgwxauto - Python 微信自动化工具
|
|
4
|
+
|
|
5
|
+
基于 UIAutomation 的微信自动化 Python 库,支持 Windows Qt 版本微信客户端。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
from .ai import AIClient, AIConfig, AIResponder
|
|
10
|
+
from .client import WeChatClient
|
|
11
|
+
from .features.messaging.forwarder import (
|
|
12
|
+
ForwardPayload,
|
|
13
|
+
ForwardRuleHandler,
|
|
14
|
+
ForwardTarget,
|
|
15
|
+
GroupForwardRule,
|
|
16
|
+
)
|
|
17
|
+
from .features.messaging.listener import MessageEvent, WeChatGroupListener
|
|
18
|
+
from .features.messaging.processor import (
|
|
19
|
+
AsyncCallbackHandler,
|
|
20
|
+
CallbackHandler,
|
|
21
|
+
ForwardAction,
|
|
22
|
+
MessageAction,
|
|
23
|
+
MessageHandler,
|
|
24
|
+
ReplyAction,
|
|
25
|
+
WeChatGroupProcessor,
|
|
26
|
+
)
|
|
27
|
+
from .core.exceptions import (
|
|
28
|
+
WeChatError,
|
|
29
|
+
WeChatNotFoundError,
|
|
30
|
+
WeChatNotConnectedError,
|
|
31
|
+
ControlNotFoundError,
|
|
32
|
+
TargetNotFoundError,
|
|
33
|
+
RegistryError,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__author__ = "盘古科技"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"WeChatClient",
|
|
40
|
+
"AIClient",
|
|
41
|
+
"AIConfig",
|
|
42
|
+
"AIResponder",
|
|
43
|
+
"MessageEvent",
|
|
44
|
+
"WeChatGroupListener",
|
|
45
|
+
"MessageAction",
|
|
46
|
+
"ReplyAction",
|
|
47
|
+
"ForwardAction",
|
|
48
|
+
"MessageHandler",
|
|
49
|
+
"CallbackHandler",
|
|
50
|
+
"AsyncCallbackHandler",
|
|
51
|
+
"WeChatGroupProcessor",
|
|
52
|
+
"ForwardTarget",
|
|
53
|
+
"ForwardPayload",
|
|
54
|
+
"GroupForwardRule",
|
|
55
|
+
"ForwardRuleHandler",
|
|
56
|
+
"WeChatError",
|
|
57
|
+
"WeChatNotFoundError",
|
|
58
|
+
"WeChatNotConnectedError",
|
|
59
|
+
"ControlNotFoundError",
|
|
60
|
+
"TargetNotFoundError",
|
|
61
|
+
"RegistryError",
|
|
62
|
+
]
|
pgwxauto/_version.py
ADDED
pgwxauto/ai.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""通用 AI 调用模块。
|
|
3
|
+
|
|
4
|
+
目标:
|
|
5
|
+
让自动回复场景只需要传入 base_url、api_format、model、api_key 即可使用。
|
|
6
|
+
|
|
7
|
+
支持格式:
|
|
8
|
+
- completions: OpenAI-compatible /chat/completions
|
|
9
|
+
- responses: OpenAI Responses API
|
|
10
|
+
- anthropic: Anthropic-compatible /messages
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import socket
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Dict, List, Literal, Optional
|
|
21
|
+
|
|
22
|
+
from .features.messaging.listener import MessageEvent
|
|
23
|
+
|
|
24
|
+
ApiFormat = Literal["completions", "responses", "anthropic"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_SYSTEM_PROMPT = """你正在微信群聊里回复消息。
|
|
28
|
+
要求:
|
|
29
|
+
1. 回复自然、简短,像真人聊天。
|
|
30
|
+
2. 不要说自己是 AI。
|
|
31
|
+
3. 不要每次都解释太多。
|
|
32
|
+
4. 如果消息不需要回复,可以只返回空字符串。
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class AIConfig:
|
|
38
|
+
"""AI 接口配置。"""
|
|
39
|
+
|
|
40
|
+
base_url: str
|
|
41
|
+
model: str
|
|
42
|
+
api_key: str
|
|
43
|
+
api_format: ApiFormat = "completions"
|
|
44
|
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT
|
|
45
|
+
temperature: float = 0.7
|
|
46
|
+
max_tokens: int = 300
|
|
47
|
+
timeout: float = 60.0
|
|
48
|
+
enable_thinking: Optional[bool] = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AIClient:
|
|
52
|
+
"""轻量级 AI 客户端。"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, config: AIConfig):
|
|
55
|
+
self.config = config
|
|
56
|
+
self.api_format = self._normalize_api_format(config.api_format)
|
|
57
|
+
self.url = self._build_endpoint(config.base_url, self.api_format)
|
|
58
|
+
|
|
59
|
+
def chat(self, messages: List[dict], system_prompt: Optional[str] = None) -> str:
|
|
60
|
+
"""发送对话并返回文本回复。"""
|
|
61
|
+
request = self._build_request(messages, system_prompt or self.config.system_prompt)
|
|
62
|
+
headers = self._build_headers()
|
|
63
|
+
|
|
64
|
+
http_request = urllib.request.Request(
|
|
65
|
+
url=self.url,
|
|
66
|
+
data=json.dumps(request, ensure_ascii=False).encode("utf-8"),
|
|
67
|
+
headers=headers,
|
|
68
|
+
method="POST",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
with urllib.request.urlopen(http_request, timeout=self.config.timeout) as response:
|
|
73
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
74
|
+
except urllib.error.HTTPError as exc:
|
|
75
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
76
|
+
raise RuntimeError(self._format_http_error(exc.code, body)) from exc
|
|
77
|
+
except urllib.error.URLError as exc:
|
|
78
|
+
reason = exc.reason
|
|
79
|
+
if isinstance(reason, socket.gaierror):
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
f"AI 接口域名解析失败,请检查网络、DNS、代理或 base_url: {self.config.base_url}"
|
|
82
|
+
) from exc
|
|
83
|
+
raise RuntimeError(f"AI 接口网络请求失败: {reason}") from exc
|
|
84
|
+
|
|
85
|
+
result = self._extract_text(data)
|
|
86
|
+
if not result:
|
|
87
|
+
raise RuntimeError(f"AI 接口返回为空: {json.dumps(data, ensure_ascii=False)}")
|
|
88
|
+
return self._sanitize_output(result)
|
|
89
|
+
|
|
90
|
+
def _build_request(self, messages: List[dict], system_prompt: str) -> dict:
|
|
91
|
+
if self.api_format == "completions":
|
|
92
|
+
request = {
|
|
93
|
+
"model": self.config.model,
|
|
94
|
+
"messages": [
|
|
95
|
+
{"role": "system", "content": system_prompt},
|
|
96
|
+
*messages,
|
|
97
|
+
],
|
|
98
|
+
"temperature": self.config.temperature,
|
|
99
|
+
"max_tokens": self.config.max_tokens,
|
|
100
|
+
}
|
|
101
|
+
if self.config.enable_thinking is not None:
|
|
102
|
+
request["enable_thinking"] = self.config.enable_thinking
|
|
103
|
+
return request
|
|
104
|
+
|
|
105
|
+
if self.api_format == "responses":
|
|
106
|
+
return {
|
|
107
|
+
"model": self.config.model,
|
|
108
|
+
"input": [
|
|
109
|
+
{
|
|
110
|
+
"role": "system",
|
|
111
|
+
"content": [{"type": "input_text", "text": system_prompt}],
|
|
112
|
+
},
|
|
113
|
+
*[
|
|
114
|
+
{
|
|
115
|
+
"role": message["role"],
|
|
116
|
+
"content": [{"type": "input_text", "text": message["content"]}],
|
|
117
|
+
}
|
|
118
|
+
for message in messages
|
|
119
|
+
],
|
|
120
|
+
],
|
|
121
|
+
"temperature": self.config.temperature,
|
|
122
|
+
"max_output_tokens": self.config.max_tokens,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if self.api_format == "anthropic":
|
|
126
|
+
return {
|
|
127
|
+
"model": self.config.model,
|
|
128
|
+
"system": system_prompt,
|
|
129
|
+
"messages": messages,
|
|
130
|
+
"max_tokens": self.config.max_tokens,
|
|
131
|
+
"temperature": self.config.temperature,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
raise ValueError(f"不支持的 api_format: {self.api_format}")
|
|
135
|
+
|
|
136
|
+
def _build_headers(self) -> Dict[str, str]:
|
|
137
|
+
headers = {
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
"Accept": "application/json",
|
|
140
|
+
"Cache-Control": "no-cache",
|
|
141
|
+
"User-Agent": (
|
|
142
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
143
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
|
144
|
+
),
|
|
145
|
+
"Authorization": f"Bearer {self.config.api_key}",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if self.api_format == "anthropic":
|
|
149
|
+
headers.pop("Authorization", None)
|
|
150
|
+
headers["x-api-key"] = self.config.api_key
|
|
151
|
+
headers["anthropic-version"] = "2023-06-01"
|
|
152
|
+
|
|
153
|
+
return headers
|
|
154
|
+
|
|
155
|
+
def _extract_text(self, data: dict) -> str:
|
|
156
|
+
if self.api_format == "completions":
|
|
157
|
+
return (
|
|
158
|
+
data.get("choices", [{}])[0]
|
|
159
|
+
.get("message", {})
|
|
160
|
+
.get("content", "")
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if self.api_format == "responses":
|
|
164
|
+
if data.get("output_text"):
|
|
165
|
+
return data["output_text"]
|
|
166
|
+
for item in data.get("output", []) or []:
|
|
167
|
+
for content in item.get("content", []) or []:
|
|
168
|
+
if content.get("type") == "output_text" and content.get("text"):
|
|
169
|
+
return content["text"]
|
|
170
|
+
return ""
|
|
171
|
+
|
|
172
|
+
if self.api_format == "anthropic":
|
|
173
|
+
return "\n".join(
|
|
174
|
+
item.get("text", "")
|
|
175
|
+
for item in data.get("content", []) or []
|
|
176
|
+
if item.get("type") == "text" and item.get("text")
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return ""
|
|
180
|
+
|
|
181
|
+
def _format_http_error(self, status: int, body: str) -> str:
|
|
182
|
+
lower = body.lower()
|
|
183
|
+
if status in (401, 403) and any(word in lower for word in ("api key", "apikey", "auth", "unauthorized", "permission")):
|
|
184
|
+
return f"AI 认证失败,请检查 api_key。HTTP {status}: {body}"
|
|
185
|
+
if status == 404:
|
|
186
|
+
return f"AI endpoint 不存在,请检查 base_url 或 api_format。URL={self.url} HTTP {status}: {body}"
|
|
187
|
+
if "model" in lower and any(word in lower for word in ("not found", "invalid", "not exist", "unsupported")):
|
|
188
|
+
return f"AI 模型不可用,请检查 model。HTTP {status}: {body}"
|
|
189
|
+
return f"AI HTTP 请求失败。URL={self.url} HTTP {status}: {body}"
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _normalize_api_format(api_format: str) -> ApiFormat:
|
|
193
|
+
if api_format == "response":
|
|
194
|
+
return "responses"
|
|
195
|
+
if api_format not in {"completions", "responses", "anthropic"}:
|
|
196
|
+
raise ValueError("api_format must be one of: completions, responses, anthropic")
|
|
197
|
+
return api_format # type: ignore[return-value]
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _build_endpoint(base_url: str, api_format: ApiFormat) -> str:
|
|
201
|
+
if not base_url or not base_url.strip():
|
|
202
|
+
raise ValueError("base_url must not be empty")
|
|
203
|
+
|
|
204
|
+
normalized = base_url.strip()
|
|
205
|
+
if not normalized.lower().startswith(("http://", "https://")):
|
|
206
|
+
normalized = f"https://{normalized}"
|
|
207
|
+
normalized = normalized.rstrip("/")
|
|
208
|
+
path = AIClient._get_url_path(normalized)
|
|
209
|
+
|
|
210
|
+
if api_format == "completions":
|
|
211
|
+
if AIClient._has_path_suffix(path, ["/chat/completions", "/v1/chat/completions", "/completions", "/v1/completions"]):
|
|
212
|
+
return normalized
|
|
213
|
+
if AIClient._has_path_suffix(path, ["/v1"]):
|
|
214
|
+
return f"{normalized}/chat/completions"
|
|
215
|
+
return f"{normalized}/v1/chat/completions"
|
|
216
|
+
|
|
217
|
+
if api_format == "responses":
|
|
218
|
+
if AIClient._has_path_suffix(path, ["/responses", "/v1/responses"]):
|
|
219
|
+
return normalized
|
|
220
|
+
if AIClient._has_path_suffix(path, ["/v1"]):
|
|
221
|
+
return f"{normalized}/responses"
|
|
222
|
+
return f"{normalized}/v1/responses"
|
|
223
|
+
|
|
224
|
+
if api_format == "anthropic":
|
|
225
|
+
if AIClient._has_path_suffix(path, ["/messages", "/v1/messages"]):
|
|
226
|
+
return normalized
|
|
227
|
+
if AIClient._has_path_suffix(path, ["/v1"]):
|
|
228
|
+
return f"{normalized}/messages"
|
|
229
|
+
return f"{normalized}/v1/messages"
|
|
230
|
+
|
|
231
|
+
raise ValueError(f"不支持的 api_format: {api_format}")
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _get_url_path(url: str) -> str:
|
|
235
|
+
marker = "://"
|
|
236
|
+
if marker not in url:
|
|
237
|
+
return ""
|
|
238
|
+
path_start = url.find("/", url.find(marker) + len(marker))
|
|
239
|
+
return url[path_start:] if path_start >= 0 else ""
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _has_path_suffix(path: str, suffixes: List[str]) -> bool:
|
|
243
|
+
return any(path == suffix or path.endswith(suffix) for suffix in suffixes)
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _sanitize_output(text: str) -> str:
|
|
247
|
+
return str(text or "").strip().strip("\"'")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class AIResponder:
|
|
251
|
+
"""面向微信群自动回复的 AI 回调封装。"""
|
|
252
|
+
|
|
253
|
+
def __init__(
|
|
254
|
+
self,
|
|
255
|
+
client: AIClient,
|
|
256
|
+
*,
|
|
257
|
+
context_size: int = 8,
|
|
258
|
+
reply_on_at: bool = True,
|
|
259
|
+
):
|
|
260
|
+
self.client = client
|
|
261
|
+
self.context_size = context_size
|
|
262
|
+
self.reply_on_at = reply_on_at
|
|
263
|
+
self.contexts: Dict[str, List[dict]] = {}
|
|
264
|
+
|
|
265
|
+
def __call__(self, event: MessageEvent) -> str:
|
|
266
|
+
if self.reply_on_at and not event.is_at_me:
|
|
267
|
+
return ""
|
|
268
|
+
|
|
269
|
+
content = self._strip_at(event.content, event.group_nickname)
|
|
270
|
+
if not content:
|
|
271
|
+
return ""
|
|
272
|
+
|
|
273
|
+
context = self.contexts.setdefault(event.group, [])
|
|
274
|
+
context.append({"role": "user", "content": content})
|
|
275
|
+
del context[:-self.context_size]
|
|
276
|
+
|
|
277
|
+
reply = self.client.chat(context)
|
|
278
|
+
if reply:
|
|
279
|
+
context.append({"role": "assistant", "content": reply})
|
|
280
|
+
del context[:-self.context_size]
|
|
281
|
+
return reply
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _strip_at(content: str, nickname: Optional[str]) -> str:
|
|
285
|
+
if not nickname:
|
|
286
|
+
return content.strip()
|
|
287
|
+
return (
|
|
288
|
+
content
|
|
289
|
+
.replace(f"@{nickname}\u2005", "")
|
|
290
|
+
.replace(f"@{nickname}", "")
|
|
291
|
+
.strip()
|
|
292
|
+
)
|
pgwxauto/client.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
pgwxauto 客户端
|
|
4
|
+
|
|
5
|
+
pgwxauto 的主入口。
|
|
6
|
+
"""
|
|
7
|
+
from .core.exceptions import WeChatNotFoundError
|
|
8
|
+
from .core.window import WeChatWindow
|
|
9
|
+
from .features.chat import ChatWindow
|
|
10
|
+
from .features.groups import GroupManager
|
|
11
|
+
from .features.messaging.listener import OutgoingMessageRegistry
|
|
12
|
+
from .features.messaging.processor import MessageHandler, WeChatGroupProcessor
|
|
13
|
+
from .utils.logger import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WeChatClient:
|
|
19
|
+
"""
|
|
20
|
+
pgwxauto 客户端
|
|
21
|
+
|
|
22
|
+
用于在 Windows 上自动化操作微信的主类。
|
|
23
|
+
|
|
24
|
+
用法:
|
|
25
|
+
wx = WeChatClient()
|
|
26
|
+
wx.connect()
|
|
27
|
+
|
|
28
|
+
# 发送消息给联系人
|
|
29
|
+
wx.chat_window.send_to("大号", "Hello!")
|
|
30
|
+
|
|
31
|
+
# 发送消息给群聊
|
|
32
|
+
wx.chat_window.send_to("测试群", "Hello!", target_type='group')
|
|
33
|
+
|
|
34
|
+
# 批量发送
|
|
35
|
+
wx.chat_window.batch_send(["群1", "群2"], "Hello!")
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, auto_connect: bool = False):
|
|
39
|
+
"""
|
|
40
|
+
初始化微信客户端。
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
auto_connect: 如果为 True,则在初始化时自动连接
|
|
44
|
+
"""
|
|
45
|
+
self._window = WeChatWindow()
|
|
46
|
+
self._chat_window: ChatWindow = None
|
|
47
|
+
self._group_manager: GroupManager = None
|
|
48
|
+
self._services = []
|
|
49
|
+
self._outgoing_registry = OutgoingMessageRegistry(120.0)
|
|
50
|
+
|
|
51
|
+
if auto_connect:
|
|
52
|
+
self.connect()
|
|
53
|
+
|
|
54
|
+
def connect(self) -> bool:
|
|
55
|
+
"""
|
|
56
|
+
连接微信窗口。
|
|
57
|
+
|
|
58
|
+
流程:
|
|
59
|
+
1. 检查并修复注册表(RunningState)
|
|
60
|
+
2. 查找并绑定微信窗口
|
|
61
|
+
3. 初始化 UIAutomation
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
bool: 连接成功返回 True
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
WeChatNotFoundError: 未找到微信时抛出
|
|
68
|
+
"""
|
|
69
|
+
logger.info("正在连接微信...")
|
|
70
|
+
result = self._window.connect()
|
|
71
|
+
if result:
|
|
72
|
+
self._chat_window = ChatWindow(self._window)
|
|
73
|
+
self._group_manager = GroupManager(self._window)
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
def disconnect(self) -> None:
|
|
77
|
+
"""断开微信连接"""
|
|
78
|
+
for service in list(self._services):
|
|
79
|
+
service.stop()
|
|
80
|
+
self._services.clear()
|
|
81
|
+
self._window.disconnect()
|
|
82
|
+
self._chat_window = None
|
|
83
|
+
self._group_manager = None
|
|
84
|
+
logger.info("已断开微信连接")
|
|
85
|
+
|
|
86
|
+
def process_groups(
|
|
87
|
+
self,
|
|
88
|
+
groups,
|
|
89
|
+
handlers,
|
|
90
|
+
*,
|
|
91
|
+
ignore_client_sent: bool = True,
|
|
92
|
+
block: bool = False,
|
|
93
|
+
**options,
|
|
94
|
+
) -> WeChatGroupProcessor:
|
|
95
|
+
"""统一处理多个群聊消息。
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
groups: 群聊名称列表。
|
|
99
|
+
handlers: 一个或多个 MessageHandler。
|
|
100
|
+
ignore_client_sent: 是否忽略本库发送后回流的消息。
|
|
101
|
+
block: 是否阻塞当前线程运行监听循环。
|
|
102
|
+
**options: 传给监听器的调度参数,例如 tick、batch_size、tail_size、
|
|
103
|
+
reply_on_at、group_nicknames。
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
WeChatGroupProcessor: 处理器实例,可调用 stop() 停止。
|
|
107
|
+
"""
|
|
108
|
+
if not self.is_connected:
|
|
109
|
+
self.connect()
|
|
110
|
+
|
|
111
|
+
if isinstance(handlers, MessageHandler):
|
|
112
|
+
normalized_handlers = [handlers]
|
|
113
|
+
else:
|
|
114
|
+
normalized_handlers = list(handlers)
|
|
115
|
+
|
|
116
|
+
processor = WeChatGroupProcessor(
|
|
117
|
+
self,
|
|
118
|
+
groups,
|
|
119
|
+
normalized_handlers,
|
|
120
|
+
ignore_client_sent=ignore_client_sent,
|
|
121
|
+
**options,
|
|
122
|
+
)
|
|
123
|
+
self._services.append(processor)
|
|
124
|
+
return processor.start(block=block)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def window(self) -> WeChatWindow:
|
|
128
|
+
"""获取窗口管理器"""
|
|
129
|
+
return self._window
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def chat_window(self) -> ChatWindow:
|
|
133
|
+
"""获取聊天窗口页面,用于发送消息"""
|
|
134
|
+
if not self._chat_window:
|
|
135
|
+
raise WeChatNotFoundError("未连接到微信")
|
|
136
|
+
return self._chat_window
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def group_manager(self) -> GroupManager:
|
|
140
|
+
"""获取群组管理器,用于群操作"""
|
|
141
|
+
if not self._group_manager:
|
|
142
|
+
raise WeChatNotFoundError("未连接到微信")
|
|
143
|
+
return self._group_manager
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def is_connected(self) -> bool:
|
|
147
|
+
"""检查是否已连接微信"""
|
|
148
|
+
return self._window.is_connected
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def outgoing_registry(self) -> OutgoingMessageRegistry:
|
|
152
|
+
"""获取客户端级共享的已发送消息注册表。"""
|
|
153
|
+
return self._outgoing_registry
|
|
154
|
+
|
|
155
|
+
def __enter__(self):
|
|
156
|
+
"""上下文管理器入口"""
|
|
157
|
+
if not self.is_connected:
|
|
158
|
+
self.connect()
|
|
159
|
+
return self
|
|
160
|
+
|
|
161
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
162
|
+
"""上下文管理器出口"""
|
|
163
|
+
self.disconnect()
|
|
164
|
+
return False
|
pgwxauto/config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""配置项"""
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# 超时设置(秒)
|
|
7
|
+
SEARCH_TIMEOUT = 5
|
|
8
|
+
OPERATION_INTERVAL = 0.3
|
|
9
|
+
SEARCH_RETRY_COUNT = 3
|
|
10
|
+
SEARCH_RETRY_DELAY_MIN = 0.8
|
|
11
|
+
SEARCH_RETRY_DELAY_MAX = 1.5
|
|
12
|
+
SEND_RETRY_COUNT = 2
|
|
13
|
+
SEND_RECONNECT_RETRY_COUNT = 1
|
|
14
|
+
BATCH_SEND_INTERVAL_MIN = 2.0
|
|
15
|
+
BATCH_SEND_INTERVAL_MAX = 3.0
|
|
16
|
+
SEND_JITTER_MIN = 0.2
|
|
17
|
+
SEND_JITTER_MAX = 0.6
|
|
18
|
+
SEND_DEDUP_WINDOW_SECONDS = 60
|
|
19
|
+
|
|
20
|
+
# 目标验证
|
|
21
|
+
ALLOWED_GROUPS = tuple(
|
|
22
|
+
item.strip()
|
|
23
|
+
for item in os.environ.get("WECHAT_ALLOWED_GROUPS", "").split(",")
|
|
24
|
+
if item.strip()
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# 日志配置
|
|
28
|
+
LOG_LEVEL = os.environ.get('WECHAT_LOG_LEVEL', 'INFO')
|
|
29
|
+
LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
30
|
+
LOG_FILE = os.environ.get("WECHAT_LOG_FILE", str(Path.cwd() / "pgwxauto.log"))
|
|
31
|
+
SEND_AUDIT_LOG_FILE = os.environ.get(
|
|
32
|
+
"WECHAT_SEND_AUDIT_LOG_FILE",
|
|
33
|
+
str(Path.cwd() / "pgwxauto_send_audit.jsonl"),
|
|
34
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Windows 平台与 UIAutomation 底层能力。"""
|
|
3
|
+
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
ControlNotFoundError,
|
|
6
|
+
RegistryError,
|
|
7
|
+
TargetNotFoundError,
|
|
8
|
+
UIAError,
|
|
9
|
+
WeChatError,
|
|
10
|
+
WeChatNotConnectedError,
|
|
11
|
+
WeChatNotFoundError,
|
|
12
|
+
)
|
|
13
|
+
from .uia_wrapper import UIAWrapper
|
|
14
|
+
from .window import WeChatWindow
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"WeChatWindow",
|
|
18
|
+
"UIAWrapper",
|
|
19
|
+
"WeChatError",
|
|
20
|
+
"WeChatNotFoundError",
|
|
21
|
+
"WeChatNotConnectedError",
|
|
22
|
+
"UIAError",
|
|
23
|
+
"ControlNotFoundError",
|
|
24
|
+
"TargetNotFoundError",
|
|
25
|
+
"RegistryError",
|
|
26
|
+
]
|