myagent-ai 1.20.3 → 1.20.5
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/agents/main_agent.py +157 -23
- 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/core/web_control.py +704 -0
- package/package.json +1 -1
- package/skills/docx_skill.py +10 -2
- package/skills/pdf_skill.py +13 -3
- package/web/api_server.py +628 -0
- package/web/ui/chat/chat.css +26 -4
- package/web/ui/chat/chat_container.html +22 -1
- package/web/ui/chat/chat_main.js +130 -1
- package/web/ui/chat/flow_engine.js +21 -0
- package/web/ui/index.html +87 -6
- package/worklog.md +61 -0
package/agents/main_agent.py
CHANGED
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import json
|
|
10
|
+
import os
|
|
10
11
|
import re
|
|
11
12
|
from typing import Any, Callable, Dict, List, Optional
|
|
12
13
|
|
|
@@ -76,6 +77,18 @@ class MainAgent(BaseAgent):
|
|
|
76
77
|
- **发送文件给用户**: 用 `file_send` 工具(参数: file_path=文件路径, description=说明),当你生成或处理了文件需要返回给用户时使用
|
|
77
78
|
- **播放音频**: 用 `playaudio` 工具(参数: url=音乐链接或file_path=本地文件路径),在聊天中内嵌播放音频(支持QQ音乐、YouTube音乐、本地MP3/WAV等),播放时自动关闭语音合成
|
|
78
79
|
- **播放视频**: 用 `playvideo` 工具(参数: url=视频链接或file_path=本地文件路径),在聊天中内嵌播放视频(支持抖音、YouTube、B站、本地MP4等),播放时自动关闭语音合成
|
|
80
|
+
- **网页控制**: 用 `web_control` 工具在聊天中打开一个可控制的浏览器面板,可浏览网页、点击元素、填写表单、滚动页面、执行JS、管理Cookie等。
|
|
81
|
+
- 打开: `{"action": "open", "url": "https://example.com"}` — 首次使用会自动创建会话,后续可传 session_id 复用
|
|
82
|
+
- 导航: `{"action": "navigate", "url": "https://...", "session_id": "xxx"}`
|
|
83
|
+
- 获取内容: `{"action": "get_content", "what": "text|html|url|title|links|images|forms|inputs", "session_id": "xxx"}`
|
|
84
|
+
- 点击元素: `{"action": "click", "selector": "CSS选择器", "session_id": "xxx"}`
|
|
85
|
+
- 填写输入: `{"action": "fill", "selector": "CSS选择器", "value": "内容", "session_id": "xxx"}`
|
|
86
|
+
- 滚动页面: `{"action": "scroll", "direction": "up|down|top|bottom", "distance": 300, "session_id": "xxx"}`
|
|
87
|
+
- 执行JS: `{"action": "evaluate", "script": "JavaScript代码", "session_id": "xxx"}`
|
|
88
|
+
- 管理Cookie: `{"action": "set_cookies", "cookies": [{"name":"k","value":"v","domain":"example.com"}], "session_id": "xxx"}` 或 `{"action": "get_cookies", "session_id": "xxx"}`
|
|
89
|
+
- 等待: `{"action": "wait", "time": 1000}` 毫秒 或 `{"action": "wait", "selector": ".result", "timeout": 10}` 秒
|
|
90
|
+
- 关闭: `{"action": "close", "session_id": "xxx"}`
|
|
91
|
+
- 注意: 网页通过服务端代理加载,用户可在面板中手动操作。复杂交互建议先用 get_content 查看页面结构再用 click/fill。
|
|
79
92
|
- **主动召回记忆**: 用 `recall_memory` 工具(参数: keyword=关键字, time_point=可选时间点如"2025-01", limit=数量默认5),根据关键字和时间搜索历史记忆
|
|
80
93
|
4. 准备好内容后,最后,再检查输出格式,确保满足以下要求:
|
|
81
94
|
<output>
|
|
@@ -1216,6 +1229,7 @@ class MainAgent(BaseAgent):
|
|
|
1216
1229
|
tool_result = await self._execute_v2_tool(
|
|
1217
1230
|
tool_name, parms, timeout, context, task_id,
|
|
1218
1231
|
stream_callback=stream_callback,
|
|
1232
|
+
sent_files=_sent_files,
|
|
1219
1233
|
)
|
|
1220
1234
|
|
|
1221
1235
|
# 发送工具结果事件
|
|
@@ -1552,6 +1566,7 @@ class MainAgent(BaseAgent):
|
|
|
1552
1566
|
context: AgentContext,
|
|
1553
1567
|
task_id: str,
|
|
1554
1568
|
stream_callback: Optional[Callable] = None,
|
|
1569
|
+
sent_files: Optional[List[Dict[str, Any]]] = None,
|
|
1555
1570
|
) -> Dict[str, Any]:
|
|
1556
1571
|
"""V2 工具执行"""
|
|
1557
1572
|
result = {"success": False, "output": "", "error": ""}
|
|
@@ -1634,18 +1649,14 @@ class MainAgent(BaseAgent):
|
|
|
1634
1649
|
# [v1.16.18] 使用当前作用域的 stream_callback(而非 context._stream_callback)
|
|
1635
1650
|
_fresult = await _fskill.execute(_fpath, _fdesc, stream_callback=stream_callback)
|
|
1636
1651
|
result = {"success": True, "output": json.dumps(_fresult, ensure_ascii=False, indent=2), "data": _fresult}
|
|
1637
|
-
# [v1.18.5] 追踪发送的文件,用于持久化到会话记忆
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
"size": _fresult.get("size", 0),
|
|
1646
|
-
})
|
|
1647
|
-
except NameError:
|
|
1648
|
-
pass # _sent_files 不在当前作用域,跳过文件追踪
|
|
1652
|
+
# [v1.18.5→21.1] 追踪发送的文件,用于持久化到会话记忆
|
|
1653
|
+
if sent_files is not None and _fresult.get("success") and _fresult.get("file_id"):
|
|
1654
|
+
sent_files.append({
|
|
1655
|
+
"id": _fresult["file_id"],
|
|
1656
|
+
"name": _fresult.get("name", ""),
|
|
1657
|
+
"type": _fresult.get("type", ""),
|
|
1658
|
+
"size": _fresult.get("size", 0),
|
|
1659
|
+
})
|
|
1649
1660
|
except Exception as _fse:
|
|
1650
1661
|
result = {"success": False, "error": f"文件发送失败: {_fse}"}
|
|
1651
1662
|
logger.warning(f"[{task_id}] file_send 工具异常: {_fse}")
|
|
@@ -1727,17 +1738,14 @@ class MainAgent(BaseAgent):
|
|
|
1727
1738
|
# 标记为媒体文件,前端渲染内嵌播放器
|
|
1728
1739
|
_fresult["_media_type"] = _media_type
|
|
1729
1740
|
result = {"success": True, "output": f"已发送{_media_type}文件: {_fpath.name}", "data": _fresult}
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
})
|
|
1739
|
-
except NameError:
|
|
1740
|
-
pass
|
|
1741
|
+
if sent_files is not None and _fresult.get("file_id"):
|
|
1742
|
+
sent_files.append({
|
|
1743
|
+
"id": _fresult["file_id"],
|
|
1744
|
+
"name": _fresult.get("name", ""),
|
|
1745
|
+
"type": _fresult.get("type", ""),
|
|
1746
|
+
"size": _fresult.get("size", 0),
|
|
1747
|
+
"_media_type": _media_type,
|
|
1748
|
+
})
|
|
1741
1749
|
else:
|
|
1742
1750
|
result = {"success": False, "error": _fresult.get("error", "文件发送失败")}
|
|
1743
1751
|
else:
|
|
@@ -1747,12 +1755,138 @@ class MainAgent(BaseAgent):
|
|
|
1747
1755
|
result = {"success": False, "error": f"播放工具异常: {_me}"}
|
|
1748
1756
|
logger.warning(f"[{task_id}] {tool_name} 工具异常: {_me}")
|
|
1749
1757
|
|
|
1758
|
+
elif tool_name == "web_control":
|
|
1759
|
+
# [v1.21.0] 网页控制器 — 在聊天中打开可控制的浏览器面板
|
|
1760
|
+
try:
|
|
1761
|
+
from core.web_control import get_web_control_manager
|
|
1762
|
+
_wc_mgr = get_web_control_manager()
|
|
1763
|
+
_wc_action = params.get("action", "open")
|
|
1764
|
+
_wc_session_id = params.get("session_id", "").strip()
|
|
1765
|
+
|
|
1766
|
+
# 自动获取或创建会话
|
|
1767
|
+
_wc_session = None
|
|
1768
|
+
if _wc_session_id:
|
|
1769
|
+
_wc_session = _wc_mgr.get_session(_wc_session_id)
|
|
1770
|
+
if not _wc_session:
|
|
1771
|
+
_wc_session = _wc_mgr.create_session()
|
|
1772
|
+
_wc_session_id = _wc_session.session_id
|
|
1773
|
+
|
|
1774
|
+
if _wc_action == "open":
|
|
1775
|
+
# 打开面板 — 发送 SSE 事件让前端弹出控制面板
|
|
1776
|
+
_wc_url = params.get("url", "").strip()
|
|
1777
|
+
if _wc_url:
|
|
1778
|
+
_wc_session.current_url = _wc_url
|
|
1779
|
+
if stream_callback:
|
|
1780
|
+
stream_callback({
|
|
1781
|
+
"type": "v2_web_control",
|
|
1782
|
+
"data": {
|
|
1783
|
+
"action": "open",
|
|
1784
|
+
"session_id": _wc_session_id,
|
|
1785
|
+
"url": _wc_url,
|
|
1786
|
+
"panel_url": f"/api/web_control/panel?sid={_wc_session_id}",
|
|
1787
|
+
}
|
|
1788
|
+
})
|
|
1789
|
+
result = {
|
|
1790
|
+
"success": True,
|
|
1791
|
+
"output": f"已打开网页控制面板 (session: {_wc_session_id})" + (f",URL: {_wc_url}" if _wc_url else ""),
|
|
1792
|
+
"session_id": _wc_session_id,
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
elif _wc_action == "close":
|
|
1796
|
+
_wc_mgr.close_session(_wc_session_id)
|
|
1797
|
+
if stream_callback:
|
|
1798
|
+
stream_callback({
|
|
1799
|
+
"type": "v2_web_control",
|
|
1800
|
+
"data": {"action": "close", "session_id": _wc_session_id}
|
|
1801
|
+
})
|
|
1802
|
+
result = {"success": True, "output": f"已关闭网页控制面板 (session: {_wc_session_id})"}
|
|
1803
|
+
|
|
1804
|
+
elif _wc_action == "navigate":
|
|
1805
|
+
_wc_url = params.get("url", "").strip()
|
|
1806
|
+
if not _wc_url:
|
|
1807
|
+
result = {"success": False, "error": "请提供 url 参数"}
|
|
1808
|
+
else:
|
|
1809
|
+
_wc_session.current_url = _wc_url
|
|
1810
|
+
if stream_callback:
|
|
1811
|
+
stream_callback({
|
|
1812
|
+
"type": "v2_web_control",
|
|
1813
|
+
"data": {"action": "navigate", "session_id": _wc_session_id, "url": _wc_url}
|
|
1814
|
+
})
|
|
1815
|
+
result = {"success": True, "output": f"正在导航到: {_wc_url}"}
|
|
1816
|
+
|
|
1817
|
+
elif _wc_action in ("set_cookies", "get_cookies"):
|
|
1818
|
+
# Cookie 操作 — 服务端直接处理
|
|
1819
|
+
if _wc_action == "set_cookies":
|
|
1820
|
+
_cookies = params.get("cookies", [])
|
|
1821
|
+
if isinstance(_cookies, str):
|
|
1822
|
+
import json as _jc
|
|
1823
|
+
try:
|
|
1824
|
+
_cookies = _jc.loads(_cookies)
|
|
1825
|
+
except:
|
|
1826
|
+
_cookies = []
|
|
1827
|
+
for _c in _cookies:
|
|
1828
|
+
if isinstance(_c, dict):
|
|
1829
|
+
_c_domain = _c.get("domain", "").lstrip(".")
|
|
1830
|
+
_c_name = _c.get("name", "")
|
|
1831
|
+
_c_value = _c.get("value", "")
|
|
1832
|
+
if _c_domain and _c_name:
|
|
1833
|
+
_wc_session.cookies[f"{_c_domain}::{_c_name}"] = _c_value
|
|
1834
|
+
result = {"success": True, "output": f"已设置 {len(_cookies)} 个 cookie", "total_cookies": len(_wc_session.cookies)}
|
|
1835
|
+
else:
|
|
1836
|
+
_cookie_list = []
|
|
1837
|
+
for _ck, _cv in _wc_session.cookies.items():
|
|
1838
|
+
_parts = _ck.split("::", 1)
|
|
1839
|
+
if len(_parts) == 2:
|
|
1840
|
+
_cookie_list.append({"domain": _parts[0], "name": _parts[1], "value": _cv})
|
|
1841
|
+
result = {"success": True, "output": json.dumps(_cookie_list, ensure_ascii=False), "cookies": _cookie_list}
|
|
1842
|
+
|
|
1843
|
+
else:
|
|
1844
|
+
# 其他操作(click, fill, scroll, evaluate, get_content, wait, screenshot)
|
|
1845
|
+
# 通过命令队列下发到客户端执行,阻塞等待结果
|
|
1846
|
+
_wc_params = {k: v for k, v in params.items() if k not in ("action", "session_id")}
|
|
1847
|
+
_wc_timeout = int(params.get("timeout", timeout))
|
|
1848
|
+
_wc_result = await _wc_mgr.queue_command(
|
|
1849
|
+
session_id=_wc_session_id,
|
|
1850
|
+
action=_wc_action,
|
|
1851
|
+
params=_wc_params,
|
|
1852
|
+
timeout=min(_wc_timeout, 60), # 最大 60 秒
|
|
1853
|
+
)
|
|
1854
|
+
result = _wc_result
|
|
1855
|
+
|
|
1856
|
+
except Exception as _wce:
|
|
1857
|
+
result = {"success": False, "error": f"网页控制器异常: {_wce}"}
|
|
1858
|
+
logger.warning(f"[{task_id}] web_control 工具异常: {_wce}")
|
|
1859
|
+
|
|
1750
1860
|
elif self.skills:
|
|
1751
1861
|
exec_result = await self.skills.execute(tool_name, **params)
|
|
1752
1862
|
if exec_result is None:
|
|
1753
1863
|
result["error"] = f"技能 {tool_name} 返回了空结果"
|
|
1754
1864
|
else:
|
|
1755
1865
|
result = exec_result.to_dict()
|
|
1866
|
+
# [v1.21.1] Skill 生成文件后自动通过 file_send 发送给前端
|
|
1867
|
+
if exec_result.success and exec_result.files:
|
|
1868
|
+
try:
|
|
1869
|
+
from skills.file_send import FileSendSkill
|
|
1870
|
+
_auto_fsend = FileSendSkill()
|
|
1871
|
+
for _auto_fpath in exec_result.files:
|
|
1872
|
+
if _auto_fpath and os.path.isfile(_auto_fpath):
|
|
1873
|
+
_auto_fres = await _auto_fsend.execute(
|
|
1874
|
+
_auto_fpath, stream_callback=stream_callback)
|
|
1875
|
+
if _auto_fres.get("success") and _auto_fres.get("file_id"):
|
|
1876
|
+
# 记录发送的 file_id
|
|
1877
|
+
if not result.get("_sent_file_ids"):
|
|
1878
|
+
result["_sent_file_ids"] = []
|
|
1879
|
+
result["_sent_file_ids"].append(_auto_fres["file_id"])
|
|
1880
|
+
# 追踪到 sent_files(持久化到会话记忆)
|
|
1881
|
+
if sent_files is not None:
|
|
1882
|
+
sent_files.append({
|
|
1883
|
+
"id": _auto_fres["file_id"],
|
|
1884
|
+
"name": _auto_fres.get("name", ""),
|
|
1885
|
+
"type": _auto_fres.get("type", ""),
|
|
1886
|
+
"size": _auto_fres.get("size", 0),
|
|
1887
|
+
})
|
|
1888
|
+
except Exception as _afe:
|
|
1889
|
+
logger.warning(f"[{task_id}] 自动 file_send 失败: {_afe}")
|
|
1756
1890
|
else:
|
|
1757
1891
|
result["error"] = f"未知工具: {tool_name}"
|
|
1758
1892
|
|
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 ""
|