myagent-ai 1.20.1 → 1.20.3

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:
@@ -55,10 +55,12 @@ class ChatBotManager:
55
55
  try:
56
56
  bot = self._create_bot(cfg, message_handler)
57
57
  if bot:
58
- self._bots[cfg.platform] = bot
59
- logger.info(f"聊天平台已配置: {cfg.platform}")
58
+ # 使用唯一 id 作为 key,支持多实例
59
+ key = cfg.id or cfg.platform
60
+ self._bots[key] = bot
61
+ logger.info(f"聊天平台已配置: {cfg.display_name or key}")
60
62
  except Exception as e:
61
- logger.error(f"平台 {cfg.platform} 初始化失败: {e}")
63
+ logger.error(f"平台 {cfg.display_name or cfg.platform} 初始化失败: {e}")
62
64
 
63
65
  def _create_bot(
64
66
  self,
@@ -115,6 +117,17 @@ class ChatBotManager:
115
117
  **config.extra,
116
118
  )
117
119
 
120
+ elif platform == "whatsapp":
121
+ from chatbot.whatsapp_bot import WhatsAppBot
122
+ return WhatsAppBot(
123
+ token=config.token,
124
+ app_id=config.app_id,
125
+ app_secret=config.app_secret,
126
+ allowed_users=config.allowed_users,
127
+ message_handler=message_handler,
128
+ **config.extra,
129
+ )
130
+
118
131
  else:
119
132
  logger.warning(f"不支持的平台: {platform}")
120
133
  return None
@@ -0,0 +1,179 @@
1
+ """
2
+ chatbot/whatsapp_bot.py - WhatsApp 机器人
3
+ ===========================================
4
+ 使用 whatsapp-web.js 或 Baileys 库接入 WhatsApp。
5
+ 支持二维码绑定。
6
+ 纯 Python 实现,异步运行。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import time
13
+ from typing import Optional, List
14
+
15
+ from chatbot.base import BaseChatBot, ChatMessage, ChatResponse
16
+
17
+ try:
18
+ import aiohttp
19
+ HAS_AIOHTTP = True
20
+ except ImportError:
21
+ HAS_AIOHTTP = False
22
+
23
+
24
+ class WhatsAppBot(BaseChatBot):
25
+ """
26
+ WhatsApp 机器人适配器。
27
+
28
+ 配置要求:
29
+ - token: Access Token (从 Meta Developer Portal 获取)
30
+ - app_id: App ID
31
+ - app_secret: App Secret
32
+ - webhook_url: Webhook URL (可选,用于接收消息)
33
+ - phone_number_id: Phone Number ID (从 Meta 获取)
34
+ - verify_token: Webhook 验证 Token (自动生成)
35
+
36
+ 支持两种模式:
37
+ 1. Cloud API 模式: 使用 Meta WhatsApp Business API
38
+ 2. 二维码绑定模式: 通过 QR Code 绑定(需要 Baileys 库)
39
+ """
40
+
41
+ platform_name = "whatsapp"
42
+
43
+ def __init__(self, **kwargs):
44
+ super().__init__(**kwargs)
45
+ self._app_id = kwargs.get("app_id", "")
46
+ self._app_secret = kwargs.get("app_secret", "")
47
+ self._phone_number_id = self.config.get("phone_number_id", "")
48
+ self._verify_token = self.config.get("verify_token", "")
49
+ self._webhook_base = self.config.get("webhook_base", "")
50
+ self._qrcode_callback = self.config.get("qrcode_callback", None)
51
+ self._qr_code = ""
52
+ self._connected = False
53
+
54
+ async def start(self):
55
+ """启动 WhatsApp 机器人"""
56
+ if not self.token:
57
+ self.logger.error("WhatsApp Access Token 未配置")
58
+ return
59
+
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 凭据。"
72
+ )
73
+ self._connected = False
74
+
75
+ async def stop(self):
76
+ """停止 WhatsApp 机器人"""
77
+ self._running = False
78
+ self._connected = False
79
+ self.logger.info("WhatsApp 机器人已停止")
80
+
81
+ async def send_message(self, response: ChatResponse) -> bool:
82
+ """发送消息到 WhatsApp"""
83
+ if not self._connected or not response.chat_id:
84
+ return False
85
+
86
+ 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
+ }
102
+
103
+ async with aiohttp.ClientSession() as session:
104
+ async with session.post(url, headers=headers, json=payload) as resp:
105
+ if resp.status == 200:
106
+ return True
107
+ error_text = await resp.text()
108
+ self.logger.error(f"WhatsApp 发送失败 ({resp.status}): {error_text}")
109
+ return False
110
+ except Exception as e:
111
+ self.logger.error(f"发送消息失败: {e}")
112
+ return False
113
+
114
+ # ==========================================================================
115
+ # Webhook 处理
116
+ # ==========================================================================
117
+
118
+ def verify_webhook(self, mode: str, token: str, challenge: str) -> Optional[str]:
119
+ """验证 Webhook"""
120
+ if mode == "subscribe" and token == self._verify_token:
121
+ return challenge
122
+ return None
123
+
124
+ async def handle_webhook_event(self, event_data: dict):
125
+ """处理 Webhook 事件"""
126
+ try:
127
+ for entry in event_data.get("entry", []):
128
+ for change in entry.get("changes", []):
129
+ value = change.get("value", {})
130
+ messages = value.get("messages", [])
131
+ contacts = value.get("contacts", [])
132
+
133
+ # 构建联系人映射
134
+ contact_map = {}
135
+ for c in contacts:
136
+ wa_id = c.get("wa_id", "")
137
+ name = c.get("profile", {}).get("name", "")
138
+ contact_map[wa_id] = name
139
+
140
+ for msg in messages:
141
+ msg_type = msg.get("type", "")
142
+ if msg_type != "text":
143
+ continue
144
+
145
+ from_id = msg.get("from", "")
146
+ text_body = msg.get("text", {}).get("body", "")
147
+ msg_id = msg.get("id", "")
148
+ timestamp = msg.get("timestamp", "")
149
+
150
+ username = contact_map.get(from_id, from_id)
151
+
152
+ message = ChatMessage(
153
+ platform=self.platform_name,
154
+ chat_id=from_id,
155
+ user_id=from_id,
156
+ username=username,
157
+ text=text_body,
158
+ is_group=False,
159
+ reply_to=msg_id,
160
+ raw_data={"timestamp": timestamp, "msg_id": msg_id},
161
+ )
162
+ await self._handle_message(message)
163
+ except Exception as e:
164
+ self.logger.error(f"处理 Webhook 事件失败: {e}")
165
+
166
+ # ==========================================================================
167
+ # 二维码绑定
168
+ # ==========================================================================
169
+
170
+ def get_qr_code(self) -> str:
171
+ """获取当前 QR Code(用于绑定模式)"""
172
+ return self._qr_code
173
+
174
+ 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 接收消息")
179
+ return ""
package/config.py CHANGED
@@ -105,15 +105,19 @@ class ModelEntry:
105
105
 
106
106
  @dataclass
107
107
  class ChatPlatformConfig:
108
- """单个聊天平台配置"""
108
+ """单个聊天平台配置(支持多实例,每个实例有唯一 id)"""
109
+ id: str = "" # 唯一标识符,如 "telegram_8616478723" 或自动生成
109
110
  enabled: bool = False
110
- platform: str = "" # telegram | discord | feishu | qq | wechat
111
+ platform: str = "" # telegram | discord | feishu | qq | wechat | whatsapp
111
112
  token: str = "" # Bot Token
112
113
  app_id: str = "" # App ID (某些平台需要)
113
114
  app_secret: str = "" # App Secret
114
115
  webhook_url: str = "" # Webhook URL
115
116
  allowed_users: List[str] = field(default_factory=list) # 允许的用户白名单(空=全部)
116
117
  extra: Dict[str, Any] = field(default_factory=dict) # 平台特有配置
118
+ bind_agent: str = "" # 绑定的 Agent path(空=不绑定/使用默认)
119
+ bind_agents: List[str] = field(default_factory=list) # 绑定的多个 Agent path(v1.20.2: 支持多 agent 绑定)
120
+ display_name: str = "" # 显示名称,如 "Telegram:8616478723"
117
121
 
118
122
 
119
123
  @dataclass
@@ -263,6 +267,7 @@ class ConfigManager:
263
267
  "MYAGENT_FEISHU_APP_SECRET": "feishu",
264
268
  "MYAGENT_QQ_TOKEN": "qq",
265
269
  "MYAGENT_WECHAT_TOKEN": "wechat",
270
+ "MYAGENT_WHATSAPP_TOKEN": "whatsapp",
266
271
  }
267
272
  for env_key, platform_name in platform_env.items():
268
273
  token = os.environ.get(env_key)
@@ -277,10 +282,39 @@ class ConfigManager:
277
282
  cp.enabled = True
278
283
  return
279
284
  cp = ChatPlatformConfig(platform=platform, token=token, enabled=True)
285
+ self._auto_platform_id(cp)
280
286
  self._config.chat_platforms.append(cp)
281
287
 
288
+ def _auto_platform_id(self, cp: ChatPlatformConfig):
289
+ """为平台配置自动生成唯一 ID 和显示名称"""
290
+ import re
291
+ if not cp.id:
292
+ if cp.platform == 'telegram' and cp.token:
293
+ # 从 token 提取 bot_id: 8616478723:AAFKir8DQ4nzYUiddhifLCKUc5K2MfHcv9U
294
+ match = re.match(r'^(\d+):', cp.token)
295
+ bot_id = match.group(1) if match else str(len(self._config.chat_platforms))
296
+ cp.id = f"telegram_{bot_id}"
297
+ cp.display_name = f"Telegram:{bot_id}"
298
+ elif cp.platform == 'whatsapp' and cp.token:
299
+ match = re.match(r'^(\d+)', cp.token)
300
+ phone_id = match.group(1) if match else str(len(self._config.chat_platforms))
301
+ cp.id = f"whatsapp_{phone_id}"
302
+ cp.display_name = f"WhatsApp:{phone_id}"
303
+ elif cp.token:
304
+ cp.id = f"{cp.platform}_{cp.token[:8]}"
305
+ cp.display_name = f"{cp.platform.capitalize()}:{cp.token[:8]}"
306
+ else:
307
+ idx = sum(1 for p in self._config.chat_platforms if p.platform == cp.platform)
308
+ cp.id = f"{cp.platform}_{idx}"
309
+ cp.display_name = f"{cp.platform.capitalize()} {idx + 1}"
310
+ if not cp.display_name:
311
+ cp.display_name = cp.id or cp.platform
312
+
282
313
  def _apply_defaults(self):
283
314
  """应用平台相关的默认值"""
315
+ # [v1.20.2] 为旧配置自动填充平台 id 和 display_name
316
+ for cp in self._config.chat_platforms:
317
+ self._auto_platform_id(cp)
284
318
  if not self._config.memory.db_path:
285
319
  self._config.memory.db_path = str(self.data_dir / "memory.db")
286
320
  # data_dir 由 property 自动计算为 _data_dir / "data",无需在此设置
@@ -341,12 +375,19 @@ class ConfigManager:
341
375
  setattr(target, key, value)
342
376
 
343
377
  def get_chat_platform(self, platform: str) -> Optional[ChatPlatformConfig]:
344
- """获取指定聊天平台配置"""
378
+ """获取指定聊天平台配置(兼容旧接口,返回第一个匹配的)"""
345
379
  for cp in self._config.chat_platforms:
346
380
  if cp.platform == platform:
347
381
  return cp
348
382
  return None
349
383
 
384
+ def get_chat_platform_by_id(self, platform_id: str) -> Optional[ChatPlatformConfig]:
385
+ """通过唯一 ID 获取聊天平台配置"""
386
+ for cp in self._config.chat_platforms:
387
+ if cp.id == platform_id:
388
+ return cp
389
+ return None
390
+
350
391
  def get_enabled_platforms(self) -> List[ChatPlatformConfig]:
351
392
  """获取所有启用的聊天平台"""
352
393
  return [cp for cp in self._config.chat_platforms if cp.enabled]
package/memory/manager.py CHANGED
@@ -306,10 +306,11 @@ class MemoryManager:
306
306
  """
307
307
  conn = self._get_conn()
308
308
  # 只排除纯内部审计条目,保留 tool_call/tool_result 供前端展示
309
+ # [v1.20.2] 使用 rowid 作为二级排序,防止同一秒内的消息顺序不确定
309
310
  sql = """SELECT * FROM memories
310
311
  WHERE session_id = ? AND category = 'session' AND role != ''
311
312
  AND key NOT IN ('llm_output', 'llm_input', 'tool_result_raw', 'conversation_insight')
312
- ORDER BY created_at ASC LIMIT ?"""
313
+ ORDER BY created_at ASC, rowid ASC LIMIT ?"""
313
314
  rows = conn.execute(sql, (session_id, limit)).fetchall()
314
315
  entries = [MemoryEntry.from_row(row) for row in rows]
315
316
  if include_roles:
@@ -319,9 +320,9 @@ class MemoryManager:
319
320
  def get_conversation_all(self, session_id, limit=5000) -> List[MemoryEntry]:
320
321
  """获取全量对话历史(包含所有内部条目),用于完整回溯。"""
321
322
  conn = self._get_conn()
322
- sql = """SELECT * FROM memories
323
+ sql = """SELECT * FROM memories
323
324
  WHERE session_id = ? AND category = 'session' AND role != ''
324
- ORDER BY created_at ASC LIMIT ?"""
325
+ ORDER BY created_at ASC, rowid ASC LIMIT ?"""
325
326
  rows = conn.execute(sql, (session_id, limit)).fetchall()
326
327
  return [MemoryEntry.from_row(row) for row in rows]
327
328
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.20.1",
3
+ "version": "1.20.3",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {
@@ -43,4 +43,4 @@
43
43
  "python": ">=3.10",
44
44
  "node": ">=18"
45
45
  }
46
- }
46
+ }