myagent-ai 1.20.2 → 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.
@@ -74,6 +74,8 @@ class MainAgent(BaseAgent):
74
74
  - **执行命令**: 用 `command` 或 `command_run` 工具
75
75
  - **文件操作**: 用 `file_read` / `file_write` / `file_list` 等文件工具
76
76
  - **发送文件给用户**: 用 `file_send` 工具(参数: file_path=文件路径, description=说明),当你生成或处理了文件需要返回给用户时使用
77
+ - **播放音频**: 用 `playaudio` 工具(参数: url=音乐链接或file_path=本地文件路径),在聊天中内嵌播放音频(支持QQ音乐、YouTube音乐、本地MP3/WAV等),播放时自动关闭语音合成
78
+ - **播放视频**: 用 `playvideo` 工具(参数: url=视频链接或file_path=本地文件路径),在聊天中内嵌播放视频(支持抖音、YouTube、B站、本地MP4等),播放时自动关闭语音合成
77
79
  - **主动召回记忆**: 用 `recall_memory` 工具(参数: keyword=关键字, time_point=可选时间点如"2025-01", limit=数量默认5),根据关键字和时间搜索历史记忆
78
80
  4. 准备好内容后,最后,再检查输出格式,确保满足以下要求:
79
81
  <output>
@@ -1648,6 +1650,103 @@ class MainAgent(BaseAgent):
1648
1650
  result = {"success": False, "error": f"文件发送失败: {_fse}"}
1649
1651
  logger.warning(f"[{task_id}] file_send 工具异常: {_fse}")
1650
1652
 
1653
+ elif tool_name in ("playaudio", "playvideo"):
1654
+ # [v1.20.3] 音视频播放工具 — 在聊天中内嵌播放器
1655
+ try:
1656
+ _media_url = params.get("url", "").strip()
1657
+ _media_file = params.get("file_path", "").strip()
1658
+ _media_type = "audio" if tool_name == "playaudio" else "video"
1659
+ _embed_url = None
1660
+ _embed_title = params.get("title", "")
1661
+
1662
+ if _media_url:
1663
+ # 在线链接 — 提取嵌入式播放 URL
1664
+ import re
1665
+ _url_lower = _media_url.lower()
1666
+ # YouTube: https://www.youtube.com/watch?v=xxx 或 youtu.be/xxx
1667
+ _yt_match = re.search(r'(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)([\w-]+)', _media_url)
1668
+ if _yt_match:
1669
+ _embed_url = f"https://www.youtube.com/embed/{_yt_match.group(1)}"
1670
+ _embed_title = _embed_title or "YouTube 视频"
1671
+ # Bilibili: https://www.bilibili.com/video/BVxxx 或 b23.tv/xxx
1672
+ elif 'bilibili.com' in _url_lower or 'b23.tv' in _url_lower:
1673
+ _bv_match = re.search(r'bilibili\.com/video/(BV[\w]+)', _media_url)
1674
+ if _bv_match:
1675
+ _embed_url = f"https://player.bilibili.com/player.html?bvid={_bv_match.group(1)}&autoplay=0"
1676
+ _embed_title = _embed_title or "B站视频"
1677
+ else:
1678
+ _embed_url = _media_url # b23.tv 短链接直接使用
1679
+ _embed_title = _embed_title or "B站视频"
1680
+ # QQ音乐: https://y.qq.com/n/ryqq/songDetail/xxx
1681
+ elif 'y.qq.com' in _url_lower:
1682
+ _embed_url = _media_url
1683
+ _embed_title = _embed_title or "QQ音乐"
1684
+ # 网易云音乐: https://music.163.com/song?id=xxx
1685
+ elif 'music.163.com' in _url_lower:
1686
+ _song_match = re.search(r'music\.163\.com.*[?&]id=(\d+)', _media_url)
1687
+ if _song_match:
1688
+ _embed_url = f"https://music.163.com/outchain/player?type=2&id={_song_match.group(1)}&auto=0&height=66"
1689
+ _embed_title = _embed_title or "网易云音乐"
1690
+ else:
1691
+ _embed_url = _media_url
1692
+ _embed_title = _embed_title or "网易云音乐"
1693
+ # 抖音: https://www.douyin.com/video/xxx
1694
+ elif 'douyin.com' in _url_lower:
1695
+ _embed_url = _media_url
1696
+ _embed_title = _embed_title or "抖音视频"
1697
+ else:
1698
+ # 其他 URL,尝试直接嵌入
1699
+ _embed_url = _media_url
1700
+ _embed_title = _embed_title or ("在线音乐" if _media_type == "audio" else "在线视频")
1701
+
1702
+ if _embed_url and stream_callback:
1703
+ # 在线播放 — 发送 v2_media 事件让前端渲染嵌入播放器
1704
+ stream_callback({
1705
+ "type": "v2_media",
1706
+ "data": {
1707
+ "media_type": _media_type,
1708
+ "embed_url": _embed_url,
1709
+ "title": _embed_title,
1710
+ "original_url": _media_url,
1711
+ }
1712
+ })
1713
+ result = {"success": True, "output": f"已嵌入{_embed_title}播放器: {_media_url}"}
1714
+
1715
+ elif _media_file:
1716
+ # 本地文件 — 使用 file_send 发送文件,前端渲染内嵌播放器
1717
+ from pathlib import Path as _P
1718
+ _fpath = _P(_media_file).expanduser().resolve()
1719
+ if not _fpath.exists():
1720
+ result = {"success": False, "error": f"文件不存在: {_media_file}"}
1721
+ else:
1722
+ from skills.file_send import FileSendSkill
1723
+ _fskill = FileSendSkill()
1724
+ _desc = f"{'音频' if _media_type == 'audio' else '视频'}播放: {_fpath.name}"
1725
+ _fresult = await _fskill.execute(str(_fpath), _desc, stream_callback=stream_callback)
1726
+ if _fresult.get("success"):
1727
+ # 标记为媒体文件,前端渲染内嵌播放器
1728
+ _fresult["_media_type"] = _media_type
1729
+ result = {"success": True, "output": f"已发送{_media_type}文件: {_fpath.name}", "data": _fresult}
1730
+ try:
1731
+ if _fresult.get("file_id"):
1732
+ _sent_files.append({
1733
+ "id": _fresult["file_id"],
1734
+ "name": _fresult.get("name", ""),
1735
+ "type": _fresult.get("type", ""),
1736
+ "size": _fresult.get("size", 0),
1737
+ "_media_type": _media_type,
1738
+ })
1739
+ except NameError:
1740
+ pass
1741
+ else:
1742
+ result = {"success": False, "error": _fresult.get("error", "文件发送失败")}
1743
+ else:
1744
+ result = {"success": False, "error": f"请提供 url(在线链接)或 file_path(本地文件路径)参数"}
1745
+
1746
+ except Exception as _me:
1747
+ result = {"success": False, "error": f"播放工具异常: {_me}"}
1748
+ logger.warning(f"[{task_id}] {tool_name} 工具异常: {_me}")
1749
+
1651
1750
  elif self.skills:
1652
1751
  exec_result = await self.skills.execute(tool_name, **params)
1653
1752
  if exec_result is None:
@@ -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,
@@ -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. 二维码绑定模式: 通过 QR Code 绑定(需要 Baileys 库)
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
- # Meta Cloud API 模式
61
- if self._phone_number_id:
62
- self.logger.info("WhatsApp Cloud API 模式启动")
63
- self._connected = True
64
- # Cloud API 模式通过 Webhook 接收消息,不需要主动轮询
65
- # 保持运行
66
- while self._running:
67
- await asyncio.sleep(1)
68
- else:
69
- self.logger.warning(
70
- "WhatsApp 需要配置 phone_number_id 才能使用 Cloud API 模式。"
71
- "请访问 Meta Developer Portal 获取 WhatsApp Business API 凭据。"
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._connected = False
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
- if not HAS_AIOHTTP:
88
- self.logger.error("请安装 aiohttp: pip install aiohttp")
89
- return False
90
-
91
- url = f"https://graph.facebook.com/v18.0/{self._phone_number_id}/messages"
92
- headers = {
93
- "Authorization": f"Bearer {self.token}",
94
- "Content-Type": "application/json",
95
- }
96
- payload = {
97
- "messaging_product": "whatsapp",
98
- "to": response.chat_id,
99
- "type": "text",
100
- "text": {"body": response.text[:4096]},
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"发送消息失败: {e}")
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
- # Cloud API 模式不需要 QR Code
177
- # 如需 Baileys 模式,需额外安装 node.js 依赖
178
- self.logger.info("WhatsApp Cloud API 模式不需要 QR Code 绑定,使用 Webhook 接收消息")
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');
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "myagent-whatsapp-bridge",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "WhatsApp Baileys bridge for MyAgent QR code binding",
6
+ "main": "bridge.mjs",
7
+ "type": "module",
8
+ "scripts": {
9
+ "start": "node bridge.mjs"
10
+ }
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.20.2",
3
+ "version": "1.20.4",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {
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"]
@@ -646,6 +646,40 @@ input,textarea,select{font:inherit}
646
646
  .msg-attachments-files {
647
647
  margin-top:6px; /* 文件在气泡内容下方 */
648
648
  }
649
+ /* [v1.20.3] 媒体嵌入播放器(在线音视频) */
650
+ .msg-attachments-media {
651
+ margin-bottom:10px;
652
+ }
653
+ .msg-media-embed {
654
+ background:var(--bg2);border:1px solid var(--bg4);border-radius:var(--radius-sm);
655
+ overflow:hidden;max-width:640px;
656
+ }
657
+ .msg-media-header {
658
+ display:flex;align-items:center;gap:6px;padding:6px 10px;
659
+ background:var(--bg3);font-size:12px;color:var(--text2);
660
+ }
661
+ .msg-media-icon {font-size:14px}
662
+ .msg-media-label {flex:1;font-weight:500}
663
+ .msg-media-link {
664
+ color:var(--accent);text-decoration:none;font-size:14px;padding:2px 4px;border-radius:4px;
665
+ }
666
+ .msg-media-link:hover {background:var(--accent-light)}
667
+ /* [v1.20.3] 本地音视频播放器 */
668
+ .msg-media-player {
669
+ margin-bottom:8px;
670
+ }
671
+ .msg-media-player audio,
672
+ .msg-media-player video {
673
+ border-radius:8px;
674
+ box-shadow:0 1px 4px rgba(0,0,0,.1);
675
+ }
676
+ .msg-media-title {
677
+ font-size:12px;color:var(--text2);margin-top:4px;padding:0 4px;
678
+ }
679
+ @media(max-width:768px){
680
+ .msg-media-embed iframe { max-width:100%!important; }
681
+ .msg-media-player audio, .msg-media-player video { max-width:100%!important; }
682
+ }
649
683
  .msg-image-wrapper {
650
684
  max-width:300px;border-radius:var(--radius-sm);overflow:hidden;
651
685
  border:1px solid var(--bg4);cursor:pointer;
@@ -2571,6 +2571,22 @@ function groupHistoryMessages(messages) {
2571
2571
  if (execParts.length > 0) entry.exec_events = execParts.map(function(p) { return p.data; });
2572
2572
  // [v1.19.3] 设置收集到的所有 agent 文件
2573
2573
  if (allAgentFiles.length > 0) entry._files = allAgentFiles;
2574
+ // [v1.20.3] 从 playaudio/playvideo 工具调用中重建 _media 数据(历史回放支持)
2575
+ var mediaEmbeds = [];
2576
+ for (var ei = 0; ei < parts.length; ei++) {
2577
+ var ep = parts[ei];
2578
+ if (ep.type === 'exec' && ep.data.tool_name && (ep.data.tool_name === 'playaudio' || ep.data.tool_name === 'playvideo')) {
2579
+ try {
2580
+ var mparams = typeof ep.data.params === 'string' ? JSON.parse(ep.data.params) : (ep.data.params || {});
2581
+ var murl = mparams.url || '';
2582
+ if (murl) {
2583
+ var mtype = ep.data.tool_name === 'playaudio' ? 'audio' : 'video';
2584
+ mediaEmbeds.push({ media_type: mtype, embed_url: murl, title: mparams.title || '', original_url: murl });
2585
+ }
2586
+ } catch(mpe) { /* ignore parse errors */ }
2587
+ }
2588
+ }
2589
+ if (mediaEmbeds.length > 0) entry._media = mediaEmbeds;
2574
2590
 
2575
2591
  grouped.push(entry);
2576
2592
  }
@@ -2691,13 +2707,59 @@ function _renderMessagesInner() {
2691
2707
  if (!isUser && agentFiles.length > 0) {
2692
2708
  for (const f of agentFiles) {
2693
2709
  const isImage = f.type && f.type.startsWith('image/');
2710
+ const isAudio = f.type && f.type.startsWith('audio/');
2711
+ const isVideo = f.type && f.type.startsWith('video/');
2694
2712
  if (isImage && f.id) {
2695
2713
  parts.push('<div class="msg-image-wrapper agent-image"><img src="/api/file/' + f.id + '" class="msg-image" loading="lazy" alt="' + escapeHtml(f.name || 'image') + '" onerror="this.onerror=null;this.style.background=\'var(--bg3)\';this.style.minHeight=\'60px\';this.alt=\'[图片加载失败]\'" onclick="openFileViewer(\'' + f.id + '\', this.src, \'' + escapeHtml(f.name) + '\')" /></div>');
2696
2714
  }
2715
+ // [v1.20.3] 音频文件渲染为内嵌播放器
2716
+ if (isAudio && f.id) {
2717
+ parts.push('<div class="msg-media-player"><audio controls src="/api/file/' + f.id + '" style="width:100%;max-width:480px" preload="metadata" onplay="if(typeof muteTTS===\'function\')muteTTS()" onended="if(typeof unmuteTTS===\'function\')unmuteTTS()"></audio><div class="msg-media-title">' + escapeHtml(f.name || '音频') + '</div></div>');
2718
+ }
2719
+ // [v1.20.3] 视频文件渲染为内嵌播放器
2720
+ if (isVideo && f.id) {
2721
+ parts.push('<div class="msg-media-player"><video controls src="/api/file/' + f.id + '" style="width:100%;max-width:640px;border-radius:8px" preload="metadata" onplay="if(typeof muteTTS===\'function\')muteTTS()" onended="if(typeof unmuteTTS===\'function\')unmuteTTS()"></video><div class="msg-media-title">' + escapeHtml(f.name || '视频') + '</div></div>');
2722
+ }
2697
2723
  }
2698
2724
  }
2699
2725
  return parts.length > 0 ? '<div class="msg-attachments msg-attachments-images">' + parts.join('') + '</div>' : '';
2700
2726
  })();
2727
+ // [v1.20.3] 在线媒体嵌入播放器(YouTube/B站/抖音/QQ音乐/网易云等)
2728
+ const mediaEmbedHtml = (() => {
2729
+ const mediaList = (msg._media || []);
2730
+ if (!mediaList || mediaList.length === 0) return '';
2731
+ let parts = [];
2732
+ for (const m of mediaList) {
2733
+ const isAudio = m.media_type === 'audio';
2734
+ let embedUrl = m.embed_url || '';
2735
+ const title = m.title || (isAudio ? '在线音乐' : '在线视频');
2736
+ const origUrl = m.original_url || embedUrl;
2737
+ if (!embedUrl) continue;
2738
+ // 前端 URL → 嵌入 URL 转换(历史回放时 embed_url 可能是原始 URL)
2739
+ if (embedUrl && !embedUrl.includes('/embed/') && !embedUrl.includes('/player')) {
2740
+ const ytMatch = embedUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
2741
+ if (ytMatch) { embedUrl = 'https://www.youtube.com/embed/' + ytMatch[1]; }
2742
+ const biliMatch = embedUrl.match(/bilibili\.com\/video\/(BV[\w]+)/);
2743
+ if (biliMatch) { embedUrl = 'https://player.bilibili.com/player.html?bvid=' + biliMatch[1] + '&autoplay=0'; }
2744
+ const neteaseMatch = embedUrl.match(/music\.163\.com.*[?&]id=(\d+)/);
2745
+ if (neteaseMatch) { embedUrl = 'https://music.163.com/outchain/player?type=2&id=' + neteaseMatch[1] + '&auto=0&height=66'; }
2746
+ }
2747
+ if (isAudio) {
2748
+ parts.push('<div class="msg-media-embed msg-media-audio">' +
2749
+ '<div class="msg-media-header"><span class="msg-media-icon">🎵</span><span class="msg-media-label">' + escapeHtml(title) + '</span>' +
2750
+ '<a class="msg-media-link" href="' + escapeHtml(origUrl) + '" target="_blank" rel="noopener" title="在新窗口打开">↗</a></div>' +
2751
+ '<iframe src="' + escapeHtml(embedUrl) + '" style="width:100%;max-width:480px;height:80px;border:none;border-radius:8px" loading="lazy" allow="autoplay" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>' +
2752
+ '</div>');
2753
+ } else {
2754
+ parts.push('<div class="msg-media-embed msg-media-video">' +
2755
+ '<div class="msg-media-header"><span class="msg-media-icon">🎬</span><span class="msg-media-label">' + escapeHtml(title) + '</span>' +
2756
+ '<a class="msg-media-link" href="' + escapeHtml(origUrl) + '" target="_blank" rel="noopener" title="在新窗口打开">↗</a></div>' +
2757
+ '<iframe src="' + escapeHtml(embedUrl) + '" style="width:100%;max-width:640px;height:360px;border:none;border-radius:8px" loading="lazy" allow="autoplay;encrypted-media;picture-in-picture" allowfullscreen></iframe>' +
2758
+ '</div>');
2759
+ }
2760
+ }
2761
+ return parts.length > 0 ? '<div class="msg-attachments msg-attachments-media">' + parts.join('') + '</div>' : '';
2762
+ })();
2701
2763
  const fileAttachmentHtml = (() => {
2702
2764
  let parts = [];
2703
2765
  // User files(用户发送的非图片文件)
@@ -2717,12 +2779,14 @@ function _renderMessagesInner() {
2717
2779
  '</div>');
2718
2780
  }
2719
2781
  }
2720
- // Agent files (v2_file events) — 支持实时流式 _files 和历史加载的 files(只渲染非图片文件)
2782
+ // Agent files (v2_file events) — 支持实时流式 _files 和历史加载的 files(只渲染非图片/音频/视频文件)
2721
2783
  const agentFiles = (msg._files || []);
2722
2784
  if (!isUser && agentFiles.length > 0) {
2723
2785
  for (const f of agentFiles) {
2724
2786
  const isImage = f.type && f.type.startsWith('image/');
2725
- if (isImage) continue; // 图片已在 imageAttachmentHtml 中渲染
2787
+ const isAudio = f.type && f.type.startsWith('audio/');
2788
+ const isVideo = f.type && f.type.startsWith('video/');
2789
+ if (isImage || isAudio || isVideo) continue; // 图片/音视频已在上方渲染为内嵌播放器
2726
2790
  const fileId = f.id;
2727
2791
  const sizeStr = f.size ? formatFileSize(f.size) : '';
2728
2792
  const icon = _getFileIcon(f.name || f.type || '');
@@ -2845,6 +2909,7 @@ function _renderMessagesInner() {
2845
2909
  ${taskPlanHtml}
2846
2910
  ${finishReasonHtml}
2847
2911
  ${imageAttachmentHtml}
2912
+ ${mediaEmbedHtml}
2848
2913
  ${bubbleHtml}
2849
2914
  ${fileAttachmentHtml}
2850
2915
  ${streamingIndicator}
@@ -1858,6 +1858,15 @@ async function sendMessage(opts) {
1858
1858
  state.messages[msgIdx]._files.push(evt.data);
1859
1859
  throttledStreamUpdate(msgIdx);
1860
1860
  }
1861
+ } else if (evt.type === 'v2_media') {
1862
+ // [v1.20.3] Agent is embedding a media player (audio/video)
1863
+ if (evt.data) {
1864
+ if (!state.messages[msgIdx]._media) state.messages[msgIdx]._media = [];
1865
+ state.messages[msgIdx]._media.push(evt.data);
1866
+ // 播放媒体时自动静音 TTS
1867
+ if (typeof muteTTS === 'function') muteTTS();
1868
+ throttledStreamUpdate(msgIdx);
1869
+ }
1861
1870
  } else if (evt.type === 'v2_session_rename') {
1862
1871
  // [v1.15.8] 会话自动命名 — 后端通过 mainsubject 生成
1863
1872
  if (evt.data && evt.data.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{