diff --git a/README.md b/README.md index 2bc168358..5cc9173a3 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用 ## ✨ 近期更新 1. AstrBot 现已支持接入 [MCP](https://modelcontextprotocol.io/) 服务器! +2. 现已集成 [Google Agent SDK](https://google.github.io/adk-docs/) + ## ✨ 主要功能 @@ -119,6 +121,7 @@ uvx astrbot init | 微信客服 | ✔ | 私聊 | 文字、图片 | | 飞书 | ✔ | 私聊、群聊 | 文字、图片 | | 钉钉 | ✔ | 私聊、群聊 | 文字、图片 | +| Google Chat | ✔ | 私聊、群聊 | 文字、图片 | | 微信对话开放平台 | 🚧 | 计划内 | - | | Discord | 🚧 | 计划内 | - | | WhatsApp | 🚧 | 计划内 | - | @@ -131,6 +134,7 @@ uvx astrbot init | OpenAI API | ✔ | 文本生成 | 也支持 DeepSeek、Google Gemini、GLM、Kimi、xAI 等兼容 OpenAI API 的服务 | | Claude API | ✔ | 文本生成 | | | Google Gemini API | ✔ | 文本生成 | | +| Google Agent SDK | ✔ | Agent SDK | [https://google.github.io/adk-docs/](https://google.github.io/adk-docs/) | | Dify | ✔ | LLMOps | | | 阿里云百炼应用 | ✔ | LLMOps | | | Ollama | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 | diff --git a/README_en.md b/README_en.md index 249dd5e79..97b32422a 100644 --- a/README_en.md +++ b/README_en.md @@ -70,6 +70,7 @@ See docs: [Source Code Deployment](https://astrbot.app/deploy/astrbot/cli.html) | [Telegram](https://github.com/Soulter/astrbot_plugin_telegram) | ✔ | Private/Group chats | Text, Images | | [WeChat Work](https://github.com/Soulter/astrbot_plugin_wecom) | ✔ | Private chats | Text, Images, Voice | | Feishu | ✔ | Group chats | Text, Images | +| Google Chat | ✔ | Private/Group chats | Text, Images | | WeChat Open Platform | 🚧 | Planned | - | | Discord | 🚧 | Planned | - | | WhatsApp | 🚧 | Planned | - | @@ -82,6 +83,7 @@ See docs: [Source Code Deployment](https://astrbot.app/deploy/astrbot/cli.html) | OpenAI API | ✔ | Text Generation | Supports all OpenAI API-compatible services including DeepSeek, Google Gemini, GLM, Moonshot, Alibaba Cloud Bailian, Silicon Flow, xAI, etc. | | Claude API | ✔ | Text Generation | | | Google Gemini API | ✔ | Text Generation | | +| Google Agent SDK | ✔ | Agent SDK | [Documentation](https://google.github.io/adk-docs/) | | Dify | ✔ | LLMOps | | | DashScope (Alibaba Cloud) | ✔ | LLMOps | | | Ollama | ✔ | Model Loader | Local deployment for open-source LLMs (DeepSeek, Llama, etc.) | diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index f6f19ce04..154dcc550 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -221,6 +221,16 @@ "telegram_command_auto_refresh": True, "telegram_command_register_interval": 300, }, + "google_chat": { + "id": "google_chat", + "type": "google_chat", + "enable": False, + "webhook_url": "", + "verification_token": "", + "callback_server_host": "0.0.0.0", + "port": 6200, + "bot_name": "astrbot", + }, }, "items": { "active_send_mode": { @@ -320,6 +330,31 @@ "hint": "请务必填对,否则 @ 机器人将无法唤醒,只能通过前缀唤醒。", "obvious_hint": True, }, + "google_chat_webhook_url": { + "description": "Google Chat Webhook URL", + "type": "string", + "hint": "发送消息到 Google Chat 的 Webhook 地址", + }, + "google_chat_verification_token": { + "description": "Google Chat 验证 Token", + "type": "string", + "hint": "可选,用于验证来自 Google Chat 的回调请求", + }, + "google_chat_callback_server_host": { + "description": "Google Chat 回调服务器 Host", + "type": "string", + "hint": "监听回调的服务器地址", + }, + "google_chat_port": { + "description": "Google Chat 回调服务器端口", + "type": "int", + "hint": "监听回调的服务器端口", + }, + "google_chat_bot_name": { + "description": "Google Chat 机器人名称", + "type": "string", + "hint": "机器人在 Google Chat 中显示的名称", + }, }, }, "platform_settings": { @@ -630,6 +665,17 @@ "budget": 0, }, }, + "Google Agent SDK": { + "id": "google_agent_default", + "type": "google_agent_sdk", + "provider_type": "chat_completion", + "enable": False, + "key": [], + "timeout": 120, + "model_config": { + "model": "gemini-1.5-pro", + }, + }, "DeepSeek": { "id": "deepseek_default", "type": "openai_chat_completion", diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 494900564..326d5f0a0 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -74,6 +74,10 @@ async def load_platform(self, platform_config: dict): ) case "telegram": from .sources.telegram.tg_adapter import TelegramPlatformAdapter # noqa: F401 + case "google_chat": + from .sources.google_chat.google_chat_adapter import ( + GoogleChatPlatformAdapter, # noqa: F401 + ) case "wecom": from .sources.wecom.wecom_adapter import WecomPlatformAdapter # noqa: F401 case "weixin_official_account": diff --git a/astrbot/core/platform/sources/google_chat/__init__.py b/astrbot/core/platform/sources/google_chat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/astrbot/core/platform/sources/google_chat/google_chat_adapter.py b/astrbot/core/platform/sources/google_chat/google_chat_adapter.py new file mode 100644 index 000000000..5f6a5c456 --- /dev/null +++ b/astrbot/core/platform/sources/google_chat/google_chat_adapter.py @@ -0,0 +1,133 @@ +import asyncio +import uuid +import quart +import astrbot.api.message_components as Comp +from astrbot.api.platform import ( + Platform, + AstrBotMessage, + MessageMember, + MessageType, + PlatformMetadata, +) +from astrbot.api.event import MessageChain +from astrbot.core.platform.astr_message_event import MessageSesion +from .google_chat_event import GoogleChatMessageEvent +from ...register import register_platform_adapter +from astrbot import logger + + +class GoogleChatServer: + def __init__(self, adapter: "GoogleChatPlatformAdapter", config: dict): + self.adapter = adapter + self.port = int(config.get("port", 6200)) + self.host = config.get("callback_server_host", "0.0.0.0") + self.verification_token = config.get("verification_token", "") + self.server = quart.Quart(__name__) + self.server.add_url_rule( + "/astrbot-googlechat/callback", view_func=self.callback, methods=["POST"] + ) + self.shutdown_event = asyncio.Event() + + async def callback(self): + data = await quart.request.get_json() + token = quart.request.headers.get("Authorization") + if self.verification_token and token != f"Bearer {self.verification_token}": + logger.warning("Google Chat verification failed") + return {"success": False}, 403 + await self.adapter.on_event(data) + return {"success": True} + + async def start_polling(self): + logger.info( + f"Google Chat adapter listening on {self.host}:{self.port}" + ) + await self.server.run_task( + host=self.host, port=self.port, shutdown_trigger=self.shutdown_trigger + ) + + async def shutdown_trigger(self): + await self.shutdown_event.wait() + + async def shutdown(self): + self.shutdown_event.set() + + +@register_platform_adapter("google_chat", "Google Chat 适配器") +class GoogleChatPlatformAdapter(Platform): + def __init__( + self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue + ) -> None: + super().__init__(event_queue) + self.config = platform_config + self.settings = platform_settings + self.bot_name = platform_config.get("bot_name", "astrbot") + self.unique_session = platform_settings["unique_session"] + self.server = GoogleChatServer(self, platform_config) + + async def send_by_session(self, session: MessageSesion, message_chain: MessageChain): + await GoogleChatMessageEvent._send_chain(session.session_id, message_chain) + await super().send_by_session(session, message_chain) + + def meta(self) -> PlatformMetadata: + return PlatformMetadata( + name="google_chat", description="Google Chat 适配器", id=self.config.get("id") + ) + + async def on_event(self, payload: dict): + abm = await self.convert_message(payload) + if abm: + await self.handle_msg(abm) + + async def convert_message(self, payload: dict) -> AstrBotMessage | None: + if payload.get("type") != "MESSAGE": + return None + message = payload.get("message", {}) + sender = message.get("sender", {}) + text = message.get("text", "") + attachments = message.get("attachments", []) + + abm = AstrBotMessage() + abm.message_id = message.get("name", str(uuid.uuid4())) + abm.sender = MessageMember( + user_id=sender.get("name", ""), nickname=sender.get("displayName", "") + ) + abm.self_id = self.bot_name + abm.message_str = text + abm.message = [Comp.Plain(text)] if text else [] + for att in attachments: + ctype = att.get("contentType", "") + if isinstance(ctype, str) and ctype.startswith("image/"): + url = att.get("downloadUri") or att.get("imageUri") or att.get("thumbnailUri") + if url: + abm.message.append(Comp.Image(file=url, url=url)) + abm.message_str += " [图片]" + + space = payload.get("space", {}) + if space.get("type") == "ROOM": + abm.type = MessageType.GROUP_MESSAGE + abm.group_id = space.get("name") + else: + abm.type = MessageType.FRIEND_MESSAGE + abm.session_id = payload.get("responseUrl", self.config.get("webhook_url", "")) + abm.raw_message = payload + abm.timestamp = int(payload.get("eventTime", 0)) if isinstance(payload.get("eventTime"), int) else 0 + return abm + + async def handle_msg(self, abm: AstrBotMessage): + event = GoogleChatMessageEvent( + message_str=abm.message_str, + message_obj=abm, + platform_meta=self.meta(), + session_id=abm.session_id, + ) + self.commit_event(event) + + async def run(self): + await self.server.start_polling() + + async def terminate(self): + await self.server.shutdown() + logger.info("Google Chat adapter shutdown") + + def get_client(self): + return self.server diff --git a/astrbot/core/platform/sources/google_chat/google_chat_event.py b/astrbot/core/platform/sources/google_chat/google_chat_event.py new file mode 100644 index 000000000..d7337ce3f --- /dev/null +++ b/astrbot/core/platform/sources/google_chat/google_chat_event.py @@ -0,0 +1,48 @@ +import aiohttp +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.api.message_components import Plain, Image +from astrbot import logger + + +class GoogleChatMessageEvent(AstrMessageEvent): + @staticmethod + async def _send_chain(webhook_url: str, message: MessageChain): + text = "" + images = [] + for comp in message.chain: + if isinstance(comp, Plain): + text += comp.text + elif isinstance(comp, Image): + if comp.file and comp.file.startswith("http"): + images.append(comp.file) + elif comp.url and comp.url.startswith("http"): + images.append(comp.url) + else: + try: + image_url = await comp.register_to_file_service() + images.append(image_url) + except Exception as e: + logger.error(f"Failed to register image: {e}") + payload = {} + if text: + payload["text"] = text + if images: + widgets = [{"image": {"imageUrl": url}} for url in images] + payload.setdefault("cards", []).append({"sections": [{"widgets": widgets}]}) + if not payload: + payload["text"] = "" + + async with aiohttp.ClientSession() as session: + async with session.post(webhook_url, json=payload) as resp: + if resp.status != 200: + try: + error_text = await resp.text() + except Exception: + error_text = resp.status + logger.error( + f"Failed to send Google Chat message: {error_text}" + ) + + async def send(self, message: MessageChain): + await self._send_chain(self.session_id, message) + await super().send(message) diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 78337ce95..806f6a1f7 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -167,6 +167,10 @@ async def load_provider(self, provider_config: dict): from .sources.gemini_source import ( ProviderGoogleGenAI as ProviderGoogleGenAI, ) + case "google_agent_sdk": + from .sources.google_agent_sdk_source import ( + ProviderGoogleAgentSDK as ProviderGoogleAgentSDK, + ) case "sensevoice_stt_selfhost": from .sources.sensevoice_selfhosted_source import ( ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost, diff --git a/astrbot/core/provider/sources/google_agent_sdk_source.py b/astrbot/core/provider/sources/google_agent_sdk_source.py new file mode 100644 index 000000000..281d4adbe --- /dev/null +++ b/astrbot/core/provider/sources/google_agent_sdk_source.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import asyncio +from typing import Any, AsyncGenerator, List + +from astrbot.api.provider import Personality, Provider +from astrbot.core.db import BaseDatabase +from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.provider.entities import LLMResponse, ToolCallsResult +from astrbot.core.provider.func_tool_manager import FuncCall +from astrbot.core import logger +from ..register import register_provider_adapter + +try: + from google.agents import Agent +except Exception: # pragma: no cover - optional dependency + Agent = None # type: ignore + + +@register_provider_adapter( + "google_agent_sdk", "Google Agent SDK 提供商适配器" +) +class ProviderGoogleAgentSDK(Provider): + """Provider adapter using Google Agent SDK. + + This is a lightweight integration that forwards prompts to a Google Agent. + If the optional dependency is missing, initialization fails. + """ + + def __init__( + self, + provider_config: dict, + provider_settings: dict, + db_helper: BaseDatabase, + persistant_history: bool = True, + default_persona: Personality | None = None, + ) -> None: + super().__init__( + provider_config, + provider_settings, + persistant_history, + db_helper, + default_persona, + ) + + if Agent is None: + raise ImportError( + "google-agents SDK is required for google_agent_sdk provider" + ) + + self.api_key: str | None = None + keys = provider_config.get("key", []) + if keys: + self.api_key = keys[0] + self.set_model(provider_config.get("model_config", {}).get("model", "")) + # Actual Agent initialization; parameters may vary based on SDK version. + # TODO: pass additional configuration such as tools when needed. + self.agent = Agent(api_key=self.api_key, model=self.get_model()) + + def get_current_key(self) -> str: + return self.api_key or "" + + def set_key(self, key: str) -> None: + self.api_key = key + # The Agent instance might need reconfiguration with new key. + try: + self.agent.api_key = key # type: ignore[attr-defined] + except Exception: # pragma: no cover - best effort + pass + + def get_models(self) -> List[str]: # pragma: no cover - simple return + return [self.get_model()] + + async def text_chat( + self, + prompt: str, + session_id: str | None = None, + image_urls: List[str] | None = None, + func_tool: FuncCall | None = None, + contexts: List[dict] | None = None, + system_prompt: str | None = None, + tool_calls_result: ToolCallsResult | None = None, + **kwargs: Any, + ) -> LLMResponse: + """Return chat completion via Google Agent SDK.""" + if image_urls: + logger.warning("google_agent_sdk provider does not support images yet") + history = contexts or [] + if system_prompt: + history = [{"role": "system", "content": system_prompt}, *history] + # TODO: handle func_tool and tool_calls_result via ADK Tool API + try: + response = await self.agent.chat(prompt, history=history) # type: ignore[attr-defined] + except Exception as e: # pragma: no cover - runtime errors + raise Exception(f"Google Agent SDK error: {e}") from e + llm_response = LLMResponse("assistant") + llm_response.result_chain = MessageChain().message(str(response)) + llm_response.raw_completion = response + return llm_response + + async def text_chat_stream( + self, + prompt: str, + session_id: str | None = None, + image_urls: List[str] | None = None, + func_tool: FuncCall | None = None, + contexts: List[dict] | None = None, + system_prompt: str | None = None, + tool_calls_result: ToolCallsResult | None = None, + **kwargs: Any, + ) -> AsyncGenerator[LLMResponse, None]: + """Stream chat completions via Google Agent SDK.""" + llm_response = await self.text_chat( + prompt, + session_id=session_id, + image_urls=image_urls, + func_tool=func_tool, + contexts=contexts, + system_prompt=system_prompt, + tool_calls_result=tool_calls_result, + **kwargs, + ) + yield llm_response diff --git a/pyproject.toml b/pyproject.toml index a09c59c9c..01813b18e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "faiss-cpu>=1.11.0", "filelock>=3.18.0", "google-genai>=1.14.0", + "google-agents>=0.1.0", "googlesearch-python>=1.3.0", "lark-oapi>=1.4.15", "lxml-html-clean>=0.4.2",