myagent-ai 1.20.3 → 1.20.4
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.
- package/chatbot/manager.py +6 -0
- package/chatbot/whatsapp_bot.py +211 -47
- package/chatbot/whatsapp_bridge/bridge.mjs +192 -0
- package/chatbot/whatsapp_bridge/package.json +11 -0
- package/package.json +1 -1
- package/web/api_server.py +83 -0
- package/web/ui/index.html +47 -1
package/chatbot/manager.py
CHANGED
|
@@ -37,6 +37,10 @@ class ChatBotManager:
|
|
|
37
37
|
self._bots: Dict[str, BaseChatBot] = {}
|
|
38
38
|
self._session_map: Dict[str, str] = {} # session_id -> last_message
|
|
39
39
|
|
|
40
|
+
def get_bot(self, key: str):
|
|
41
|
+
"""根据 key (id 或 platform 名) 获取 bot 实例"""
|
|
42
|
+
return self._bots.get(key)
|
|
43
|
+
|
|
40
44
|
def setup_platforms(
|
|
41
45
|
self,
|
|
42
46
|
platform_configs: List[ChatPlatformConfig],
|
|
@@ -111,6 +115,8 @@ class ChatBotManager:
|
|
|
111
115
|
elif platform == "wechat":
|
|
112
116
|
from chatbot.wechat_bot import WeChatBot
|
|
113
117
|
return WeChatBot(
|
|
118
|
+
app_id=config.app_id,
|
|
119
|
+
app_secret=config.app_secret,
|
|
114
120
|
token=config.token,
|
|
115
121
|
allowed_users=config.allowed_users,
|
|
116
122
|
message_handler=message_handler,
|
package/chatbot/whatsapp_bot.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
chatbot/whatsapp_bot.py - WhatsApp 机器人
|
|
3
3
|
===========================================
|
|
4
4
|
使用 whatsapp-web.js 或 Baileys 库接入 WhatsApp。
|
|
5
|
-
|
|
5
|
+
支持二维码绑定(通过 Node.js Baileys bridge)。
|
|
6
6
|
纯 Python 实现,异步运行。
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
@@ -10,6 +10,9 @@ from __future__ import annotations
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import json
|
|
12
12
|
import time
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
13
16
|
from typing import Optional, List
|
|
14
17
|
|
|
15
18
|
from chatbot.base import BaseChatBot, ChatMessage, ChatResponse
|
|
@@ -25,17 +28,19 @@ class WhatsAppBot(BaseChatBot):
|
|
|
25
28
|
"""
|
|
26
29
|
WhatsApp 机器人适配器。
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
配置要求 (Cloud API 模式):
|
|
29
32
|
- token: Access Token (从 Meta Developer Portal 获取)
|
|
30
|
-
- app_id: App ID
|
|
31
|
-
- app_secret: App Secret
|
|
32
|
-
- webhook_url: Webhook URL (可选,用于接收消息)
|
|
33
33
|
- phone_number_id: Phone Number ID (从 Meta 获取)
|
|
34
34
|
- verify_token: Webhook 验证 Token (自动生成)
|
|
35
35
|
|
|
36
|
+
配置要求 (QR 码绑定模式):
|
|
37
|
+
- 无需 token / phone_number_id
|
|
38
|
+
- 需要 Node.js 环境 + Baileys 依赖
|
|
39
|
+
- 首次运行会生成 QR 码,扫码后自动保存会话
|
|
40
|
+
|
|
36
41
|
支持两种模式:
|
|
37
42
|
1. Cloud API 模式: 使用 Meta WhatsApp Business API
|
|
38
|
-
2.
|
|
43
|
+
2. Baileys 模式: 通过 QR Code 绑定 (推荐个人使用)
|
|
39
44
|
"""
|
|
40
45
|
|
|
41
46
|
platform_name = "whatsapp"
|
|
@@ -47,35 +52,161 @@ class WhatsAppBot(BaseChatBot):
|
|
|
47
52
|
self._phone_number_id = self.config.get("phone_number_id", "")
|
|
48
53
|
self._verify_token = self.config.get("verify_token", "")
|
|
49
54
|
self._webhook_base = self.config.get("webhook_base", "")
|
|
50
|
-
self._qrcode_callback = self.config.get("qrcode_callback", None)
|
|
51
55
|
self._qr_code = ""
|
|
52
56
|
self._connected = False
|
|
57
|
+
self._bridge_process: Optional[subprocess.Popen] = None
|
|
58
|
+
self._bridge_mode = not self._phone_number_id # 没有 phone_number_id 则使用 bridge
|
|
59
|
+
self._session_dir = str(Path(__file__).parent / "whatsapp_bridge" / "session")
|
|
53
60
|
|
|
54
61
|
async def start(self):
|
|
55
62
|
"""启动 WhatsApp 机器人"""
|
|
63
|
+
if self._bridge_mode:
|
|
64
|
+
await self._start_bridge_mode()
|
|
65
|
+
else:
|
|
66
|
+
await self._start_cloud_mode()
|
|
67
|
+
|
|
68
|
+
async def _start_cloud_mode(self):
|
|
69
|
+
"""Meta Cloud API 模式"""
|
|
56
70
|
if not self.token:
|
|
57
71
|
self.logger.error("WhatsApp Access Token 未配置")
|
|
58
72
|
return
|
|
73
|
+
if not self._phone_number_id:
|
|
74
|
+
self.logger.error("Cloud API 模式需要配置 phone_number_id")
|
|
75
|
+
return
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
self.logger.info("WhatsApp Cloud API 模式启动")
|
|
78
|
+
self._connected = True
|
|
79
|
+
while self._running:
|
|
80
|
+
await asyncio.sleep(1)
|
|
81
|
+
|
|
82
|
+
async def _start_bridge_mode(self):
|
|
83
|
+
"""Baileys Bridge 模式 (QR 码绑定)"""
|
|
84
|
+
bridge_dir = Path(__file__).parent / "whatsapp_bridge"
|
|
85
|
+
bridge_script = bridge_dir / "bridge.mjs"
|
|
86
|
+
|
|
87
|
+
if not bridge_script.exists():
|
|
88
|
+
self.logger.error(f"Bridge 脚本不存在: {bridge_script}")
|
|
89
|
+
self.logger.error("请运行: cd chatbot/whatsapp_bridge && npm install @whiskeysockets/baileys qrcode-terminal")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# 检查是否已安装依赖
|
|
93
|
+
node_modules = bridge_dir / "node_modules"
|
|
94
|
+
if not node_modules.exists():
|
|
95
|
+
self.logger.info("正在安装 Baileys 依赖...")
|
|
96
|
+
try:
|
|
97
|
+
proc = await asyncio.create_subprocess_exec(
|
|
98
|
+
"npm", "install", "--production",
|
|
99
|
+
cwd=str(bridge_dir),
|
|
100
|
+
stdout=asyncio.subprocess.PIPE,
|
|
101
|
+
stderr=asyncio.subprocess.PIPE,
|
|
102
|
+
)
|
|
103
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
|
|
104
|
+
if proc.returncode != 0:
|
|
105
|
+
self.logger.error(f"Baileys 安装失败: {stderr.decode()[:500]}")
|
|
106
|
+
return
|
|
107
|
+
self.logger.info("Baileys 依赖安装完成")
|
|
108
|
+
except Exception as e:
|
|
109
|
+
self.logger.error(f"Baileys 安装异常: {e}")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
env = os.environ.copy()
|
|
113
|
+
env["SESSION_DIR"] = self._session_dir
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
self._bridge_process = await asyncio.create_subprocess_exec(
|
|
117
|
+
"node", str(bridge_script),
|
|
118
|
+
stdout=asyncio.subprocess.PIPE,
|
|
119
|
+
stderr=asyncio.subprocess.PIPE,
|
|
120
|
+
env=env,
|
|
121
|
+
cwd=str(bridge_dir),
|
|
72
122
|
)
|
|
73
|
-
self.
|
|
123
|
+
self.logger.info("WhatsApp Bridge 进程已启动")
|
|
124
|
+
|
|
125
|
+
# 启动消息读取循环
|
|
126
|
+
asyncio.create_task(self._read_bridge_output())
|
|
127
|
+
except Exception as e:
|
|
128
|
+
self.logger.error(f"Bridge 启动失败: {e}")
|
|
129
|
+
|
|
130
|
+
while self._running:
|
|
131
|
+
await asyncio.sleep(1)
|
|
132
|
+
|
|
133
|
+
async def _read_bridge_output(self):
|
|
134
|
+
"""读取 Bridge 进程的 stdout (JSON 协议)"""
|
|
135
|
+
if not self._bridge_process or not self._bridge_process.stdout:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
reader = self._bridge_process.stdout
|
|
140
|
+
while self._running and self._bridge_process.returncode is None:
|
|
141
|
+
line = await reader.readline()
|
|
142
|
+
if not line:
|
|
143
|
+
break
|
|
144
|
+
line = line.decode('utf-8', errors='replace').strip()
|
|
145
|
+
if not line:
|
|
146
|
+
continue
|
|
147
|
+
try:
|
|
148
|
+
msg = json.loads(line)
|
|
149
|
+
msg_type = msg.get("type", "")
|
|
150
|
+
|
|
151
|
+
if msg_type == "qr":
|
|
152
|
+
self._qr_code = msg.get("qr", "")
|
|
153
|
+
self.logger.info("QR 码已生成,等待扫码...")
|
|
154
|
+
|
|
155
|
+
elif msg_type == "connected":
|
|
156
|
+
self._connected = True
|
|
157
|
+
phone = msg.get("phone", "")
|
|
158
|
+
name = msg.get("name", "")
|
|
159
|
+
self.logger.info(f"WhatsApp 已连接: {name} ({phone})")
|
|
160
|
+
# 更新配置中的连接状态
|
|
161
|
+
self.config["connection_status"] = "connected"
|
|
162
|
+
self.config["connected_phone"] = phone
|
|
163
|
+
|
|
164
|
+
elif msg_type == "disconnected":
|
|
165
|
+
self._connected = False
|
|
166
|
+
reason = msg.get("reason", "unknown")
|
|
167
|
+
self.logger.warning(f"WhatsApp 已断开: {reason}")
|
|
168
|
+
self.config["connection_status"] = "disconnected"
|
|
169
|
+
|
|
170
|
+
elif msg_type == "message":
|
|
171
|
+
from_id = msg.get("from", "")
|
|
172
|
+
text = msg.get("text", "")
|
|
173
|
+
push_name = msg.get("pushName", "")
|
|
174
|
+
if from_id and text:
|
|
175
|
+
chat_msg = ChatMessage(
|
|
176
|
+
platform=self.platform_name,
|
|
177
|
+
chat_id=from_id,
|
|
178
|
+
user_id=from_id,
|
|
179
|
+
username=push_name or from_id.split('@')[0],
|
|
180
|
+
text=text,
|
|
181
|
+
is_group='@g.us' in from_id,
|
|
182
|
+
raw_data=msg,
|
|
183
|
+
)
|
|
184
|
+
await self._handle_message(chat_msg)
|
|
185
|
+
|
|
186
|
+
elif msg_type == "error":
|
|
187
|
+
error = msg.get("error", "")
|
|
188
|
+
self.logger.error(f"Bridge 错误: {error}")
|
|
189
|
+
|
|
190
|
+
except json.JSONDecodeError:
|
|
191
|
+
self.logger.debug(f"非 JSON 输出: {line[:100]}")
|
|
192
|
+
except Exception as e:
|
|
193
|
+
self.logger.error(f"Bridge 输出读取异常: {e}")
|
|
74
194
|
|
|
75
195
|
async def stop(self):
|
|
76
196
|
"""停止 WhatsApp 机器人"""
|
|
77
197
|
self._running = False
|
|
78
198
|
self._connected = False
|
|
199
|
+
|
|
200
|
+
if self._bridge_process:
|
|
201
|
+
try:
|
|
202
|
+
self._bridge_process.terminate()
|
|
203
|
+
await asyncio.sleep(1)
|
|
204
|
+
if self._bridge_process.returncode is None:
|
|
205
|
+
self._bridge_process.kill()
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
self._bridge_process = None
|
|
209
|
+
|
|
79
210
|
self.logger.info("WhatsApp 机器人已停止")
|
|
80
211
|
|
|
81
212
|
async def send_message(self, response: ChatResponse) -> bool:
|
|
@@ -83,36 +214,56 @@ class WhatsAppBot(BaseChatBot):
|
|
|
83
214
|
if not self._connected or not response.chat_id:
|
|
84
215
|
return False
|
|
85
216
|
|
|
217
|
+
if self._bridge_mode:
|
|
218
|
+
return await self._send_via_bridge(response.chat_id, response.text)
|
|
219
|
+
else:
|
|
220
|
+
return await self._send_via_cloud_api(response.chat_id, response.text)
|
|
221
|
+
|
|
222
|
+
async def _send_via_bridge(self, chat_id: str, text: str) -> bool:
|
|
223
|
+
"""通过 Bridge 发送消息"""
|
|
224
|
+
if not self._bridge_process or not self._bridge_process.stdin:
|
|
225
|
+
return False
|
|
86
226
|
try:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
227
|
+
msg = json.dumps({"action": "send", "to": chat_id, "text": text}) + "\n"
|
|
228
|
+
self._bridge_process.stdin.write(msg.encode('utf-8'))
|
|
229
|
+
await self._bridge_process.stdin.drain()
|
|
230
|
+
return True
|
|
231
|
+
except Exception as e:
|
|
232
|
+
self.logger.error(f"Bridge 发送失败: {e}")
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
async def _send_via_cloud_api(self, chat_id: str, text: str) -> bool:
|
|
236
|
+
"""通过 Cloud API 发送消息"""
|
|
237
|
+
if not HAS_AIOHTTP:
|
|
238
|
+
self.logger.error("请安装 aiohttp: pip install aiohttp")
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
url = f"https://graph.facebook.com/v18.0/{self._phone_number_id}/messages"
|
|
242
|
+
headers = {
|
|
243
|
+
"Authorization": f"Bearer {self.token}",
|
|
244
|
+
"Content-Type": "application/json",
|
|
245
|
+
}
|
|
246
|
+
payload = {
|
|
247
|
+
"messaging_product": "whatsapp",
|
|
248
|
+
"to": chat_id,
|
|
249
|
+
"type": "text",
|
|
250
|
+
"text": {"body": text[:4096]},
|
|
251
|
+
}
|
|
102
252
|
|
|
253
|
+
try:
|
|
103
254
|
async with aiohttp.ClientSession() as session:
|
|
104
255
|
async with session.post(url, headers=headers, json=payload) as resp:
|
|
105
256
|
if resp.status == 200:
|
|
106
257
|
return True
|
|
107
258
|
error_text = await resp.text()
|
|
108
|
-
self.logger.error(f"WhatsApp 发送失败 ({resp.status}): {error_text}")
|
|
259
|
+
self.logger.error(f"WhatsApp 发送失败 ({resp.status}): {error_text[:200]}")
|
|
109
260
|
return False
|
|
110
261
|
except Exception as e:
|
|
111
|
-
self.logger.error(f"
|
|
262
|
+
self.logger.error(f"Cloud API 发送失败: {e}")
|
|
112
263
|
return False
|
|
113
264
|
|
|
114
265
|
# ==========================================================================
|
|
115
|
-
# Webhook 处理
|
|
266
|
+
# Webhook 处理 (Cloud API 模式)
|
|
116
267
|
# ==========================================================================
|
|
117
268
|
|
|
118
269
|
def verify_webhook(self, mode: str, token: str, challenge: str) -> Optional[str]:
|
|
@@ -122,7 +273,7 @@ class WhatsAppBot(BaseChatBot):
|
|
|
122
273
|
return None
|
|
123
274
|
|
|
124
275
|
async def handle_webhook_event(self, event_data: dict):
|
|
125
|
-
"""处理 Webhook 事件"""
|
|
276
|
+
"""处理 Webhook 事件 (Cloud API 模式)"""
|
|
126
277
|
try:
|
|
127
278
|
for entry in event_data.get("entry", []):
|
|
128
279
|
for change in entry.get("changes", []):
|
|
@@ -130,7 +281,6 @@ class WhatsAppBot(BaseChatBot):
|
|
|
130
281
|
messages = value.get("messages", [])
|
|
131
282
|
contacts = value.get("contacts", [])
|
|
132
283
|
|
|
133
|
-
# 构建联系人映射
|
|
134
284
|
contact_map = {}
|
|
135
285
|
for c in contacts:
|
|
136
286
|
wa_id = c.get("wa_id", "")
|
|
@@ -146,7 +296,6 @@ class WhatsAppBot(BaseChatBot):
|
|
|
146
296
|
text_body = msg.get("text", {}).get("body", "")
|
|
147
297
|
msg_id = msg.get("id", "")
|
|
148
298
|
timestamp = msg.get("timestamp", "")
|
|
149
|
-
|
|
150
299
|
username = contact_map.get(from_id, from_id)
|
|
151
300
|
|
|
152
301
|
message = ChatMessage(
|
|
@@ -164,16 +313,31 @@ class WhatsAppBot(BaseChatBot):
|
|
|
164
313
|
self.logger.error(f"处理 Webhook 事件失败: {e}")
|
|
165
314
|
|
|
166
315
|
# ==========================================================================
|
|
167
|
-
#
|
|
316
|
+
# QR 码绑定
|
|
168
317
|
# ==========================================================================
|
|
169
318
|
|
|
170
319
|
def get_qr_code(self) -> str:
|
|
171
|
-
"""获取当前 QR Code
|
|
320
|
+
"""获取当前 QR Code(base64 PNG 图片)"""
|
|
172
321
|
return self._qr_code
|
|
173
322
|
|
|
174
323
|
async def generate_qr_code(self) -> str:
|
|
175
|
-
"""
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
324
|
+
"""请求生成 QR 码。Bridge 模式下,首次启动会自动生成。"""
|
|
325
|
+
if self._bridge_mode:
|
|
326
|
+
# 如果 bridge 已在运行且有 QR 码,直接返回
|
|
327
|
+
if self._qr_code:
|
|
328
|
+
return self._qr_code
|
|
329
|
+
# 如果 bridge 未运行,尝试启动
|
|
330
|
+
if not self._bridge_process or self._bridge_process.returncode is not None:
|
|
331
|
+
asyncio.create_task(self._start_bridge_mode())
|
|
332
|
+
# 等待 QR 码生成(最多 30 秒)
|
|
333
|
+
for _ in range(60):
|
|
334
|
+
await asyncio.sleep(0.5)
|
|
335
|
+
if self._qr_code:
|
|
336
|
+
return self._qr_code
|
|
337
|
+
if self._connected:
|
|
338
|
+
return "" # 已连接,不需要 QR
|
|
339
|
+
return self._qr_code
|
|
340
|
+
|
|
341
|
+
# Cloud API 模式不需要 QR
|
|
342
|
+
self.logger.info("Cloud API 模式不需要 QR Code")
|
|
179
343
|
return ""
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp Baileys Bridge
|
|
3
|
+
* ======================
|
|
4
|
+
* Provides QR code generation for WhatsApp Web binding.
|
|
5
|
+
* Communicates with Python via stdin/stdout JSON messages.
|
|
6
|
+
*
|
|
7
|
+
* Protocol:
|
|
8
|
+
* Python → Node: {"action": "start"} or {"action": "send", "to": "xxx", "text": "yyy"}
|
|
9
|
+
* Node → Python: {"type": "qr", "qr": "base64..."} | {"type": "connected", "phone": "xxx"} | {"type": "message", "from": "xxx", "text": "yyy"} | {"type": "error", "error": "..."}
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createRequire } from 'module';
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
|
|
15
|
+
let makeWASocket, useMultiFileAuthState, fetchLatestBaileysVersion, DisconnectReason;
|
|
16
|
+
let qrCodeToString;
|
|
17
|
+
|
|
18
|
+
// Try to load Baileys
|
|
19
|
+
try {
|
|
20
|
+
const baileys = require('@whiskeysockets/baileys');
|
|
21
|
+
makeWASocket = baileks.default.makeWASocket;
|
|
22
|
+
useMultiFileAuthState = baileks.default.useMultiFileAuthState;
|
|
23
|
+
fetchLatestBaileysVersion = baileks.default.fetchLatestBaileysVersion;
|
|
24
|
+
DisconnectReason = baileks.default.DisconnectReason;
|
|
25
|
+
qrCodeToString = require('qrcode-terminal').default;
|
|
26
|
+
} catch (e) {
|
|
27
|
+
// Try alternative import
|
|
28
|
+
try {
|
|
29
|
+
const baileys = require('@adiwajshing/baileys');
|
|
30
|
+
makeWASocket = baileys.makeWASocket;
|
|
31
|
+
useMultiFileAuthState = baileys.useMultiFileAuthState;
|
|
32
|
+
fetchLatestBaileysVersion = baileys.fetchLatestBaileysVersion;
|
|
33
|
+
DisconnectReason = baileys.DisconnectReason;
|
|
34
|
+
} catch (e2) {
|
|
35
|
+
console.error(JSON.stringify({type: 'error', error: 'Baileys not installed. Run: cd chatbot/whatsapp_bridge && npm install @whiskeysockets/baileys qrcode-terminal'}));
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
41
|
+
import { join, dirname } from 'path';
|
|
42
|
+
import { fileURLToPath } from 'url';
|
|
43
|
+
|
|
44
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
const SESSION_DIR = process.env.SESSION_DIR || join(__dirname, 'session');
|
|
46
|
+
|
|
47
|
+
let sock = null;
|
|
48
|
+
let isConnected = false;
|
|
49
|
+
let currentQR = '';
|
|
50
|
+
|
|
51
|
+
function send(msg) {
|
|
52
|
+
try {
|
|
53
|
+
process.stdout.write(JSON.stringify(msg) + '\n');
|
|
54
|
+
} catch (e) {
|
|
55
|
+
// ignore
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function startWhatsApp() {
|
|
60
|
+
if (!existsSync(SESSION_DIR)) {
|
|
61
|
+
mkdirSync(SESSION_DIR, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
|
|
65
|
+
|
|
66
|
+
const { version } = await fetchLatestBaileysVersion();
|
|
67
|
+
|
|
68
|
+
sock = makeWASocket({
|
|
69
|
+
version,
|
|
70
|
+
auth: {
|
|
71
|
+
creds: state,
|
|
72
|
+
keys: state,
|
|
73
|
+
},
|
|
74
|
+
printQRInTerminal: false,
|
|
75
|
+
logger: { level: 'silent' },
|
|
76
|
+
shouldIgnoreJid: () => false,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
sock.ev.on('creds.update', saveCreds);
|
|
80
|
+
|
|
81
|
+
sock.ev.on('connection.update', (update) => {
|
|
82
|
+
const { connection, lastDisconnect, qr } = update;
|
|
83
|
+
|
|
84
|
+
if (qr) {
|
|
85
|
+
// Convert QR to base64 image
|
|
86
|
+
currentQR = qr.toString('base64');
|
|
87
|
+
send({ type: 'qr', qr: currentQR });
|
|
88
|
+
// Also log to stderr (not stdout, which is for JSON protocol)
|
|
89
|
+
process.stderr.write('[WhatsApp] QR Code generated, waiting for scan...\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (connection === 'open') {
|
|
93
|
+
isConnected = true;
|
|
94
|
+
const me = sock.user;
|
|
95
|
+
send({ type: 'connected', phone: me?.id?.replace(/:.*@/, '') || '', name: me?.name || '' });
|
|
96
|
+
process.stderr.write(`[WhatsApp] Connected as ${me?.name || me?.id}\n`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (connection === 'close') {
|
|
100
|
+
isConnected = false;
|
|
101
|
+
const statusCode = lastDisconnect?.error?.output?.statusCode;
|
|
102
|
+
if (statusCode === DisconnectReason.loggedOut) {
|
|
103
|
+
send({ type: 'disconnected', reason: 'logged_out' });
|
|
104
|
+
process.stderr.write('[WhatsApp] Logged out, need to re-scan QR\n');
|
|
105
|
+
} else if (statusCode === DisconnectReason.connectionClosed) {
|
|
106
|
+
send({ type: 'disconnected', reason: 'connection_closed' });
|
|
107
|
+
process.stderr.write('[WhatsApp] Connection closed\n');
|
|
108
|
+
} else {
|
|
109
|
+
send({ type: 'disconnected', reason: 'unknown', code: statusCode });
|
|
110
|
+
process.stderr.write(`[WhatsApp] Disconnected: ${statusCode}\n`);
|
|
111
|
+
}
|
|
112
|
+
// Auto-reconnect after 5 seconds (except logged out)
|
|
113
|
+
if (statusCode !== DisconnectReason.loggedOut) {
|
|
114
|
+
setTimeout(() => startWhatsApp(), 5000);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
sock.ev.on('messages.upsert', async ({ messages }) => {
|
|
120
|
+
for (const msg of messages) {
|
|
121
|
+
if (msg.key.fromMe) continue;
|
|
122
|
+
if (msg.message?.conversation) {
|
|
123
|
+
send({
|
|
124
|
+
type: 'message',
|
|
125
|
+
from: msg.key.remoteJid,
|
|
126
|
+
fromMe: msg.key.fromMe,
|
|
127
|
+
text: msg.message.conversation,
|
|
128
|
+
id: msg.key.id,
|
|
129
|
+
pushName: msg.pushName || '',
|
|
130
|
+
});
|
|
131
|
+
} else if (msg.message?.extendedTextMessage) {
|
|
132
|
+
send({
|
|
133
|
+
type: 'message',
|
|
134
|
+
from: msg.key.remoteJid,
|
|
135
|
+
fromMe: msg.key.fromMe,
|
|
136
|
+
text: msg.message.extendedTextMessage.text,
|
|
137
|
+
id: msg.key.id,
|
|
138
|
+
pushName: msg.pushName || '',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function sendMessage(to, text) {
|
|
146
|
+
if (!sock || !isConnected) {
|
|
147
|
+
return { success: false, error: 'Not connected' };
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const jid = to.includes('@') ? to : to + '@s.whatsapp.net';
|
|
151
|
+
await sock.sendMessage(jid, { text: text.substring(0, 4096) });
|
|
152
|
+
return { success: true };
|
|
153
|
+
} catch (e) {
|
|
154
|
+
return { success: false, error: e.message };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Handle stdin messages from Python
|
|
159
|
+
process.stdin.setEncoding('utf8');
|
|
160
|
+
let buffer = '';
|
|
161
|
+
process.stdin.on('data', (chunk) => {
|
|
162
|
+
buffer += chunk;
|
|
163
|
+
const lines = buffer.split('\n');
|
|
164
|
+
buffer = lines.pop(); // keep incomplete line in buffer
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
if (!line.trim()) continue;
|
|
167
|
+
try {
|
|
168
|
+
const msg = JSON.parse(line);
|
|
169
|
+
if (msg.action === 'start') {
|
|
170
|
+
startWhatsApp().catch(e => {
|
|
171
|
+
send({ type: 'error', error: e.message });
|
|
172
|
+
});
|
|
173
|
+
} else if (msg.action === 'send') {
|
|
174
|
+
sendMessage(msg.to, msg.text).then(result => {
|
|
175
|
+
send({ type: 'send_result', ...result });
|
|
176
|
+
});
|
|
177
|
+
} else if (msg.action === 'status') {
|
|
178
|
+
send({ type: 'status', connected: isConnected, hasQR: !!currentQR });
|
|
179
|
+
} else if (msg.action === 'get_qr') {
|
|
180
|
+
if (currentQR) {
|
|
181
|
+
send({ type: 'qr', qr: currentQR });
|
|
182
|
+
} else {
|
|
183
|
+
send({ type: 'qr', qr: '' });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch (e) {
|
|
187
|
+
process.stderr.write(`[Bridge] Failed to parse message: ${e.message}\n`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
process.stderr.write('[WhatsApp Bridge] Ready, waiting for commands...\n');
|
package/package.json
CHANGED
package/web/api_server.py
CHANGED
|
@@ -332,6 +332,8 @@ class ApiServer:
|
|
|
332
332
|
r.add_post("/api/platforms/{name}/toggle", self.handle_toggle_platform)
|
|
333
333
|
r.add_post("/api/platforms/{name}/restart", self.handle_restart_platform)
|
|
334
334
|
r.add_get("/api/platforms/{name}/agents", self.handle_platform_agents)
|
|
335
|
+
r.add_get("/api/platforms/{name}/qr", self.handle_get_platform_qr)
|
|
336
|
+
r.add_post("/api/platforms/{name}/qr", self.handle_start_platform_qr)
|
|
335
337
|
# ── 模型库 CRUD ──
|
|
336
338
|
r.add_get("/api/models", self.handle_list_models)
|
|
337
339
|
r.add_post("/api/models", self.handle_add_model)
|
|
@@ -3126,6 +3128,87 @@ window.toggleFullscreen = function() {{
|
|
|
3126
3128
|
# TODO: 实现平台 Bot 热重启
|
|
3127
3129
|
return web.json_response({"ok": True, "message": f"平台 {display} 重启请求已发送(重启需服务端支持)"})
|
|
3128
3130
|
|
|
3131
|
+
# [v1.20.3] QR 码绑定相关
|
|
3132
|
+
async def handle_get_platform_qr(self, request):
|
|
3133
|
+
"""GET /api/platforms/{name}/qr - 获取平台当前 QR 码和连接状态"""
|
|
3134
|
+
name = request.match_info["name"]
|
|
3135
|
+
cp = self.core.config_mgr.get_chat_platform_by_id(name)
|
|
3136
|
+
if not cp:
|
|
3137
|
+
cp = self.core.config_mgr.get_chat_platform(name)
|
|
3138
|
+
if not cp:
|
|
3139
|
+
return web.json_response({"error": "平台不存在"}, status=404)
|
|
3140
|
+
|
|
3141
|
+
qr_code = ""
|
|
3142
|
+
connected = False
|
|
3143
|
+
status = "unknown"
|
|
3144
|
+
|
|
3145
|
+
# 从 bot 实例获取 QR 码
|
|
3146
|
+
bot = self.core.chatbot_manager.get_bot(cp.id or cp.platform)
|
|
3147
|
+
if bot and hasattr(bot, 'get_qr_code'):
|
|
3148
|
+
qr_code = bot.get_qr_code() or ""
|
|
3149
|
+
if bot and hasattr(bot, '_connected'):
|
|
3150
|
+
connected = bot._connected
|
|
3151
|
+
|
|
3152
|
+
# 从 extra 读取持久化的连接状态
|
|
3153
|
+
conn_status = cp.extra.get("connection_status", "")
|
|
3154
|
+
if connected:
|
|
3155
|
+
status = "connected"
|
|
3156
|
+
elif qr_code:
|
|
3157
|
+
status = "waiting_scan"
|
|
3158
|
+
elif conn_status:
|
|
3159
|
+
status = conn_status
|
|
3160
|
+
else:
|
|
3161
|
+
status = "not_started"
|
|
3162
|
+
|
|
3163
|
+
return web.json_response({
|
|
3164
|
+
"platform": cp.platform,
|
|
3165
|
+
"id": cp.id,
|
|
3166
|
+
"status": status,
|
|
3167
|
+
"connected": connected,
|
|
3168
|
+
"qr_code": qr_code,
|
|
3169
|
+
})
|
|
3170
|
+
|
|
3171
|
+
async def handle_start_platform_qr(self, request):
|
|
3172
|
+
"""POST /api/platforms/{name}/qr - 请求生成 QR 码(开始绑定流程)"""
|
|
3173
|
+
name = request.match_info["name"]
|
|
3174
|
+
cp = self.core.config_mgr.get_chat_platform_by_id(name)
|
|
3175
|
+
if not cp:
|
|
3176
|
+
cp = self.core.config_mgr.get_chat_platform(name)
|
|
3177
|
+
if not cp:
|
|
3178
|
+
return web.json_response({"error": "平台不存在"}, status=404)
|
|
3179
|
+
|
|
3180
|
+
if cp.platform not in ("whatsapp", "wechat"):
|
|
3181
|
+
return web.json_response({"error": f"{cp.platform} 不支持 QR 码绑定"}, status=400)
|
|
3182
|
+
|
|
3183
|
+
bot = self.core.chatbot_manager.get_bot(cp.id or cp.platform)
|
|
3184
|
+
if not bot:
|
|
3185
|
+
return web.json_response({"error": "Bot 实例未创建"}, status=400)
|
|
3186
|
+
|
|
3187
|
+
if not hasattr(bot, 'generate_qr_code'):
|
|
3188
|
+
return web.json_response({"error": "该 Bot 不支持 QR 码绑定"}, status=400)
|
|
3189
|
+
|
|
3190
|
+
try:
|
|
3191
|
+
qr_code = await bot.generate_qr_code()
|
|
3192
|
+
if qr_code:
|
|
3193
|
+
# 保存 QR 码到 extra
|
|
3194
|
+
cp.extra["connection_status"] = "waiting_scan"
|
|
3195
|
+
self.core.config_mgr.save()
|
|
3196
|
+
return web.json_response({
|
|
3197
|
+
"ok": True,
|
|
3198
|
+
"qr_code": qr_code,
|
|
3199
|
+
"status": "waiting_scan",
|
|
3200
|
+
"message": "QR 码已生成,请使用手机扫码",
|
|
3201
|
+
})
|
|
3202
|
+
else:
|
|
3203
|
+
return web.json_response({
|
|
3204
|
+
"ok": False,
|
|
3205
|
+
"status": "failed",
|
|
3206
|
+
"error": "QR 码生成失败,请检查配置",
|
|
3207
|
+
})
|
|
3208
|
+
except Exception as e:
|
|
3209
|
+
logger.error(f"QR 码生成异常 ({name}): {e}")
|
|
3210
|
+
return web.json_response({"error": f"QR 码生成异常: {e}"}, status=500)
|
|
3211
|
+
|
|
3129
3212
|
async def handle_platform_agents(self, request):
|
|
3130
3213
|
"""获取绑定到指定平台的所有 Agent"""
|
|
3131
3214
|
name = request.match_info["name"]
|
package/web/ui/index.html
CHANGED
|
@@ -1251,8 +1251,8 @@ async function renderPlatforms(){
|
|
|
1251
1251
|
<div class="mt-8 flex gap-8">
|
|
1252
1252
|
<button class="btn btn-sm ${p.enabled?'btn-danger':'btn-success'}" onclick="togglePlatform('${escHtml(pid)}',${!p.enabled})">${p.enabled?'停用':'启用'}</button>
|
|
1253
1253
|
<button class="btn btn-sm btn-ghost" onclick="showEditPlatformModal('${escHtml(pid)}')">配置</button>
|
|
1254
|
+
${supportsQR?'<button class="btn btn-sm btn-primary" onclick="showPlatformQRModal(\''+escHtml(pid)+'\')">📱 QR绑定</button>':''}
|
|
1254
1255
|
</div></div>`;
|
|
1255
|
-
}
|
|
1256
1256
|
html+='</div>';$('content').innerHTML=html;
|
|
1257
1257
|
}
|
|
1258
1258
|
async function togglePlatform(id,enable){await api(`/api/platforms/${id}`,{method:'PUT',body:JSON.stringify({enabled})});renderPlatforms()}
|
|
@@ -1336,6 +1336,52 @@ async function doDeletePlatform(id){
|
|
|
1336
1336
|
});
|
|
1337
1337
|
}
|
|
1338
1338
|
|
|
1339
|
+
// [v1.20.3] QR 码绑定模态框
|
|
1340
|
+
let _qrPollTimer=null;
|
|
1341
|
+
async function showPlatformQRModal(platformId){
|
|
1342
|
+
if(_qrPollTimer){clearInterval(_qrPollTimer);_qrPollTimer=null}
|
|
1343
|
+
$('modalContainer').innerHTML='<div class="modal-overlay" onclick="closeModal();if(_qrPollTimer){clearInterval(_qrPollTimer);_qrPollTimer=null}"><div class="modal" onclick="event.stopPropagation()" style="max-width:420px;text-align:center"><h3>📱 QR 码绑定</h3><p style="font-size:13px;color:var(--text2);margin:8px 0">使用手机扫描下方二维码以绑定账号</p><div id="qrStatus" style="margin:12px 0;padding:12px;border-radius:var(--radius-sm);background:var(--bg3);font-size:13px;color:var(--text2)">正在请求 QR 码...</div><div id="qrCodeContainer" style="display:flex;justify-content:center;padding:16px 0"><div id="qrCodeImg" style="width:256px;height:256px;border:1px solid var(--bg4);border-radius:8px;display:flex;align-items:center;justify-content:center;color:var(--text3);font-size:13px">加载中...</div></div><div id="qrActions" style="display:none;margin-top:12px"><button class="btn btn-sm btn-ghost" onclick="requestNewQR(\''+escHtml(platformId)+'\')">🔄 刷新 QR 码</button></div><div class="flex gap-8 mt-16"><button class="btn btn-ghost" onclick="closeModal();if(_qrPollTimer){clearInterval(_qrPollTimer);_qrPollTimer=null}">关闭</button></div></div></div>';
|
|
1344
|
+
var r=await api('/api/platforms/'+encodeURIComponent(platformId)+'/qr',{method:'POST'});
|
|
1345
|
+
if(r.error){$('qrStatus').textContent='错误: '+escHtml(r.error);return}
|
|
1346
|
+
if(r.qr_code){
|
|
1347
|
+
$('qrCodeImg').innerHTML='<img src="data:image/png;base64,'+r.qr_code+'" style="width:256px;height:256px;border-radius:8px" alt="QR Code">';
|
|
1348
|
+
$('qrStatus').innerHTML='<span style="color:var(--warn)">⏳ 等待扫码...</span>';
|
|
1349
|
+
$('qrActions').style.display='block';
|
|
1350
|
+
}else if(r.status==='connected'){
|
|
1351
|
+
$('qrStatus').innerHTML='<span style="color:var(--success)">✅ 已连接</span>';
|
|
1352
|
+
$('qrCodeImg').innerHTML='<div style="color:var(--success);font-size:24px;padding:40px">✅<br><span style="font-size:14px">已成功连接</span></div>';
|
|
1353
|
+
}else{$('qrStatus').textContent=r.error||'QR 码生成失败,请检查平台配置';}
|
|
1354
|
+
_qrPollTimer=setInterval(async()=>{
|
|
1355
|
+
try{
|
|
1356
|
+
var s=await api('/api/platforms/'+encodeURIComponent(platformId)+'/qr');
|
|
1357
|
+
if(s.status==='connected'){
|
|
1358
|
+
$('qrStatus').innerHTML='<span style="color:var(--success)">✅ 已连接</span>';
|
|
1359
|
+
$('qrCodeImg').innerHTML='<div style="color:var(--success);font-size:24px;padding:40px">✅<br><span style="font-size:14px">已成功连接</span></div>';
|
|
1360
|
+
$('qrActions').style.display='none';
|
|
1361
|
+
clearInterval(_qrPollTimer);_qrPollTimer=null;
|
|
1362
|
+
showToast('QR 绑定成功!','success');renderPlatforms();
|
|
1363
|
+
}else if(s.status==='waiting_scan'&&s.qr_code){
|
|
1364
|
+
$('qrCodeImg').innerHTML='<img src="data:image/png;base64,'+s.qr_code+'" style="width:256px;height:256px;border-radius:8px" alt="QR Code">';
|
|
1365
|
+
$('qrStatus').innerHTML='<span style="color:var(--warn)">⏳ 等待扫码...</span>';
|
|
1366
|
+
$('qrActions').style.display='block';
|
|
1367
|
+
}else if(s.status==='disconnected'){
|
|
1368
|
+
$('qrStatus').innerHTML='<span style="color:var(--danger)">❌ 已断开</span>';
|
|
1369
|
+
$('qrActions').style.display='block';
|
|
1370
|
+
}
|
|
1371
|
+
}catch(e){}
|
|
1372
|
+
},3000);
|
|
1373
|
+
}
|
|
1374
|
+
async function requestNewQR(platformId){
|
|
1375
|
+
$('qrStatus').textContent='正在刷新 QR 码...';
|
|
1376
|
+
$('qrCodeImg').innerHTML='加载中...';
|
|
1377
|
+
var r=await api('/api/platforms/'+encodeURIComponent(platformId)+'/qr',{method:'POST'});
|
|
1378
|
+
if(r.error){$('qrStatus').textContent='错误: '+escHtml(r.error);return}
|
|
1379
|
+
if(r.qr_code){
|
|
1380
|
+
$('qrCodeImg').innerHTML='<img src="data:image/png;base64,'+r.qr_code+'" style="width:256px;height:256px;border-radius:8px" alt="QR Code">';
|
|
1381
|
+
$('qrStatus').innerHTML='<span style="color:var(--warn)">⏳ 等待扫码...</span>';
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1339
1385
|
// Agent 选择器(从部门树选择 Agent)
|
|
1340
1386
|
async function showAgentSelectorForPlatform(inputId,containerId){
|
|
1341
1387
|
try{
|