wxpy4 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 wx4py Team
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.
README.md ADDED
@@ -0,0 +1,160 @@
1
+ # wx4py
2
+
3
+ Python 微信自动化工具 — 基于 UIAutomation 的 Windows Qt 版微信桌面客户端自动化库。
4
+
5
+ ## 功能
6
+
7
+ | 模块 | 功能 |
8
+ |------|------|
9
+ | **消息发送** | 向联系人或群聊发送文本消息、文件,支持批量发送 |
10
+ | **消息监听** | 实时监听群聊消息,基于轮询模式的稳定监听器 |
11
+ | **自动回复** | 处理器管线(Handler/Action),支持多 handler 组合 |
12
+ | **消息转发** | 跨群转发文本/图片/卡片消息,含转发规则引擎 |
13
+ | **AI 集成** | 内置 OpenAI 兼容接口的 AI 客户端,支持流式回复 |
14
+ | **群组管理** | 获取群列表、搜索群聊、管理群成员 |
15
+ | **剪贴板** | Markdown → HTML → 剪贴板,支持富文本发送 |
16
+ | **窗口控制** | 自动连接微信窗口、注册表修复、托盘恢复 |
17
+
18
+ ## 安装
19
+
20
+ ```bash
21
+ pip install wxpy4
22
+ ```
23
+
24
+ **系统要求**: Windows 10+,Qt 版微信客户端(WeChat.exe)。
25
+
26
+ ## 快速开始
27
+
28
+ ```python
29
+ from wx4py import WeChatClient
30
+
31
+ # 连接微信
32
+ wx = WeChatClient()
33
+ wx.connect()
34
+
35
+ # 发送消息给联系人
36
+ wx.chat_window.send_to("张三", "你好!")
37
+
38
+ # 发送文件到群聊
39
+ wx.chat_window.send_file_to("测试群", "report.xlsx", target_type="group")
40
+
41
+ # 批量发送
42
+ wx.chat_window.batch_send(["群1", "群2"], "通知:明天 9 点开会")
43
+
44
+ # 使用上下文管理器(自动 disconnect)
45
+ with WeChatClient() as wx:
46
+ wx.chat_window.send_to("张三", "Hello from context manager")
47
+ ```
48
+
49
+ ## 群消息监听与自动回复
50
+
51
+ ```python
52
+ from wx4py import WeChatClient, MessageHandler, ReplyAction
53
+
54
+ class EchoHandler(MessageHandler):
55
+ def handle(self, msg):
56
+ if "你好" in msg.text:
57
+ return ReplyAction(f"你好 {msg.sender}!")
58
+ return None
59
+
60
+ wx = WeChatClient()
61
+ wx.connect()
62
+ processor = wx.process_groups(["测试群"], EchoHandler())
63
+ # processor.run_forever() # 阻塞运行
64
+ processor.stop() # 或按需停止
65
+ ```
66
+
67
+ ## 消息转发
68
+
69
+ ```python
70
+ from wx4py import WeChatClient, GroupForwardRule, ForwardRuleHandler
71
+
72
+ rule = GroupForwardRule(
73
+ source_groups=["源群A"],
74
+ target_groups=["目标群B"],
75
+ text_only=True,
76
+ )
77
+ handler = ForwardRuleHandler(rule)
78
+
79
+ wx = WeChatClient()
80
+ wx.connect()
81
+ processor = wx.process_groups(["源群A"], handler)
82
+ ```
83
+
84
+ ## AI 自动回复
85
+
86
+ ```python
87
+ from wx4py import WeChatClient, AIResponder, AIConfig
88
+
89
+ config = AIConfig(
90
+ base_url="https://api.openai.com/v1",
91
+ api_key="sk-xxx",
92
+ model="gpt-4o",
93
+ )
94
+ responder = AIResponder(config, system_prompt="你是一个友好的助手")
95
+
96
+ wx = WeChatClient()
97
+ wx.connect()
98
+ processor = wx.process_groups(["客服群"], responder.to_handler())
99
+ ```
100
+
101
+ ## API 概览
102
+
103
+ ### 核心类
104
+
105
+ | 类 | 说明 |
106
+ |----|------|
107
+ | `WeChatClient` | 主入口:连接微信、获取聊天窗口和群管理器 |
108
+ | `ChatWindow` | 聊天窗口:发送消息、文件、搜索 |
109
+ | `GroupManager` | 群管理器:获取群列表、搜索群 |
110
+ | `WeChatGroupListener` | 群消息监听器:轮询新消息 |
111
+ | `WeChatGroupProcessor` | 消息处理器:管理监听生命周期 |
112
+
113
+ ### 消息处理
114
+
115
+ | 类 | 说明 |
116
+ |----|------|
117
+ | `MessageHandler` | 抽象处理器基类 |
118
+ | `CallbackHandler` | 回调式处理器(函数式风格) |
119
+ | `AsyncCallbackHandler` | 异步回调处理器 |
120
+ | `ReplyAction` | 回复动作 |
121
+ | `ForwardAction` | 转发动作 |
122
+
123
+ ### 消息转发
124
+
125
+ | 类 | 说明 |
126
+ |----|------|
127
+ | `GroupForwardRule` | 转发规则定义 |
128
+ | `ForwardRuleHandler` | 转发规则处理器 |
129
+ | `ForwardTarget` | 转发目标 |
130
+ | `ForwardPayload` | 转发载荷 |
131
+
132
+ ### AI 集成
133
+
134
+ | 类 | 说明 |
135
+ |----|------|
136
+ | `AIClient` | OpenAI 兼容 API 客户端 |
137
+ | `AIConfig` | AI 配置(base_url, api_key, model) |
138
+ | `AIResponder` | AI 自动回复器 |
139
+ | `MessageEvent` | 消息事件数据类 |
140
+
141
+ ## 技术栈
142
+
143
+ - **UIAutomation**: Windows UI 自动化框架
144
+ - **COM (comtypes)**: Windows 组件对象模型交互
145
+ - **pywin32**: Windows API 调用
146
+ - **BeautifulSoup**: HTML 解析(聊天内容解析)
147
+ - **Pillow**: 图片处理
148
+ - **Markdown**: Markdown → HTML 转换
149
+
150
+ ## 注意事项
151
+
152
+ 1. **仅支持 Windows** — 依赖 UIAutomation、pywin32、comtypes 等 Windows 专用库
153
+ 2. **仅支持 Qt 版微信** — 不支持 UWP 版(Microsoft Store 版本)
154
+ 3. **微信需要保持前台运行** — 自动化通过 UI 操作实现,微信窗口不能被最小化到系统托盘
155
+ 4. **首次使用需要管理员权限** — 用于注册 COM 组件
156
+ 5. **Python 3.8+** — 使用了 `from __future__ import annotations`
157
+
158
+ ## License
159
+
160
+ MIT
__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ wx4py - 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__ = "wx4py Team"
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
+ ]
_version.py ADDED
@@ -0,0 +1,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ """包版本号的唯一来源。"""
3
+
4
+ __version__ = "0.2.2"
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
+ )