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 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
@@ -0,0 +1,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ """包版本号的唯一来源。"""
3
+
4
+ __version__ = "0.1.2"
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
+ ]