diff --git a/04_卡火(火)/火炬_全栈消息/艾叶/SKILL.md b/04_卡火(火)/火炬_全栈消息/艾叶/SKILL.md new file mode 100644 index 00000000..c1ff9769 --- /dev/null +++ b/04_卡火(火)/火炬_全栈消息/艾叶/SKILL.md @@ -0,0 +1,172 @@ +--- +name: 艾叶 IM Bridge +description: 多平台 IM 消息网关。将个人微信、企业微信、飞书、WhatsApp、网页聊天等平台的消息桥接到卡若AI,实现跨平台 AI 对话。使用本技能当需要:(1) 配置/启动 IM 桥接 (2) 新增聊天通道 (3) 排查 IM 通道问题 (4) 对接新的消息平台 +triggers: 艾叶、IM、聊天对接、消息网关、微信对接、企业微信对接、飞书对接、WhatsApp对接、网页聊天、IM桥接、通道配置、艾叶IM +owner: 火炬 +group: 火 +version: "1.0" +updated: "2026-03-13" +--- + +# 艾叶 IM Bridge + +## 概述 + +艾叶是卡若AI的多平台 IM 消息网关,参考 OpenClaw 三层架构设计(Gateway → Channel → LLM),让任何聊天平台的消息都能路由到卡若AI进行对话,然后把 AI 回复推回对应平台。 + +**核心理念**:一个网关,任何平台,同一个 AI。 + +## 架构 + +``` + 个人微信 / 企业微信 / 飞书 / WhatsApp / 网页 + │ + ┌─────────▼─────────┐ + │ 艾叶 IM Bridge │ ← Channel Layer(通道适配) + │ (FastAPI:18900) │ + └─────────┬─────────┘ + │ HTTP POST /v1/chat + ┌─────────▼─────────┐ + │ 卡若AI 网关 │ ← LLM Layer(AI 推理) + │ (FastAPI:18080) │ + └───────────────────┘ +``` + +## 源代码位置 + +``` +/Users/karuo/Documents/个人/卡若AI/运营中枢/scripts/aiye_im_bridge/ +``` + +## 支持的通道 + +| 通道 | 对接方式 | Webhook 路径 | 状态 | +|:---|:---|:---|:---| +| 个人微信 | 中间件 Webhook(兼容存客宝/WeChatFerry/ComWeChatBot) | `/webhook/wechat_personal` | ✅ 就绪 | +| 企业微信 | 官方 API 回调 | `/webhook/wechat_work` | ✅ 就绪 | +| 飞书 | 事件订阅回调 | `/webhook/feishu` | ✅ 就绪 | +| WhatsApp | Cloud API Webhook | `/webhook/whatsapp` | ✅ 就绪 | +| 网页聊天 | REST + WebSocket | `/api/web/chat` `/ws/web/chat` `/chat` | ✅ 就绪 | + +## 快速开始 + +### 1. 启动 + +```bash +cd 运营中枢/scripts/aiye_im_bridge +bash start.sh # 默认端口 18900 +bash start.sh 19000 # 指定端口 +``` + +### 2. 配置通道 + +编辑 `config/channels.yaml`,按需启用通道并填入对应平台的凭证。首次运行会自动从 `channels.example.yaml` 复制一份。 + +### 3. 验证 + +- 访问 `http://localhost:18900` 查看欢迎页 +- 访问 `http://localhost:18900/chat` 打开网页聊天 +- 访问 `http://localhost:18900/status` 查看通道状态 + +## 各通道配置说明 + +### 个人微信 + +需要一个微信协议中间件(存客宝、WeChatFerry、ComWeChatBot 等),中间件负责微信登录和消息抓取,艾叶只做 HTTP 桥接: +1. 中间件将消息 POST 到 `http://艾叶地址/webhook/wechat_personal` +2. 艾叶处理后通过 `callback_url` 回调中间件发送回复 + +### 企业微信 + +1. 在企业微信管理后台创建自建应用 +2. 设置回调 URL 为 `http(s)://你的域名/webhook/wechat_work` +3. 在 `channels.yaml` 填入 `corp_id`、`agent_id`、`secret`、`token`、`encoding_aes_key` + +### 飞书 + +1. 在飞书开放平台创建应用 +2. 开启「机器人」能力 +3. 事件订阅地址设为 `http(s)://你的域名/webhook/feishu` +4. 订阅事件 `im.message.receive_v1` +5. 在 `channels.yaml` 填入 `app_id`、`app_secret`、`verification_token` + +### WhatsApp + +1. 在 Meta 开发者后台配置 WhatsApp Business API +2. Webhook URL 设为 `http(s)://你的域名/webhook/whatsapp` +3. 在 `channels.yaml` 填入 `phone_number_id`、`access_token`、`verify_token` + +### 网页聊天 + +默认启用。访问 `/chat` 即可使用内置聊天界面,也可通过 REST API 或 WebSocket 集成到自己的系统。 + +## 扩展新通道 + +继承 `core/channel_base.py` 的 `ChannelBase`,实现以下方法: + +```python +class MyChannel(ChannelBase): + @property + def platform(self) -> str: + return "my_platform" + + async def start(self) -> None: ... + async def stop(self) -> None: ... + async def send(self, msg: OutboundMessage) -> bool: ... + def register_routes(self, app) -> None: ... +``` + +然后在 `main.py` 的 `_register_channels()` 中注册即可。 + +## 聊天命令 + +在任何通道中发送: +- `/reset` — 重置对话上下文 +- `/status` — 查看当前会话状态 +- `/help` — 查看可用命令 + +## 管理接口 + +| 路径 | 方法 | 说明 | +|:---|:---|:---| +| `/` | GET | 欢迎页 | +| `/status` | GET | 通道状态 | +| `/health` | GET | 健康检查 | +| `/chat` | GET | 网页聊天界面 | +| `/docs` | GET | API 文档(Swagger) | + +## 目录结构 + +``` +aiye_im_bridge/ +├── main.py # 主入口 +├── start.sh # 启动脚本 +├── requirements.txt # 依赖 +├── config/ +│ ├── channels.yaml # 通道配置(不入库) +│ └── channels.example.yaml # 配置示例 +├── core/ +│ ├── channel_base.py # Channel 基类 +│ ├── router.py # 消息路由 +│ ├── session.py # 会话管理 +│ └── bridge.py # 网关桥接 +└── channels/ + ├── wechat_personal.py # 个人微信 + ├── wechat_work.py # 企业微信 + ├── feishu.py # 飞书 + ├── whatsapp.py # WhatsApp + └── web.py # 网页聊天 +``` + +## 依赖 + +- Python 3.10+ +- fastapi、uvicorn、httpx、pyyaml、websockets +- 卡若AI 网关运行中(默认 `http://127.0.0.1:18080`) + +## 与消息中枢的关系 + +- **消息中枢**(Clawdbot/Moltbot):TypeScript,OpenClaw 框架,重型多通道 AI 助手 +- **艾叶**:Python,轻量 Webhook 桥接,专注于把消息接到卡若AI网关 + +两者可共存,艾叶更适合快速对接新平台、轻量部署。 diff --git a/SKILL_REGISTRY.md b/SKILL_REGISTRY.md index 6e7d2602..19c215a9 100644 --- a/SKILL_REGISTRY.md +++ b/SKILL_REGISTRY.md @@ -1,7 +1,7 @@ # 卡若AI 技能注册表(Skill Registry) > **一张表查所有技能**。任何 AI 拿到这张表,就能按关键词找到对应技能的 SKILL.md 路径并执行。 -> 70 技能 | 14 成员 | 5 负责人 +> 71 技能 | 14 成员 | 5 负责人 > 版本:5.5 | 更新:2026-03-13 > > **技能配置、安装、删除、掌管人登记** → 见 **`运营中枢/工作台/01_技能控制台.md`**。 @@ -125,6 +125,7 @@ | F01a | 前端开发 | 火炬 | **前端开发、毛玻璃、神射手风格、毛狐狸风格、前端标准、苹果毛玻璃** | `04_卡火(火)/火炬_全栈消息/前端开发/SKILL.md` | 苹果毛玻璃风格 + 神射手/毛狐狸前端标准;官网/全站前端走本 Skill | | F01b | 全栈测试 | 火炬 | **全栈测试、功能测试、回归测试、深度测试、E2E测试、API测试、发布测试、测试验收** | `04_卡火(火)/火炬_全栈消息/全栈开发/全栈测试/SKILL.md` | 功能开发后系统化验收:前端/后端/数据库/脚本/发布引擎五维测试;**每完成一个功能必须调用** | | F02 | 消息中枢 | 火炬 | WhatsApp、Telegram | `04_卡火(火)/火炬_全栈消息/消息中枢/SKILL.md` | 多平台消息聚合 | +| F02a | **艾叶 IM Bridge** | 火炬 | **艾叶、IM、聊天对接、消息网关、微信对接、企业微信对接、飞书对接、WhatsApp对接、网页聊天、IM桥接、通道配置、艾叶IM** | `04_卡火(火)/火炬_全栈消息/艾叶/SKILL.md` | 多平台 IM 网关:个人微信/企业微信/飞书/WhatsApp/网页→卡若AI 对话 | | F03 | 读书笔记 | 火炬 | 拆解这本书、五行拆书 | `04_卡火(火)/火炬_全栈消息/读书笔记/SKILL.md` | 五行框架拆书 | | F04 | 文档清洗 | 火炬 | 文档清洗、PDF转MD | `04_卡火(火)/火炬_全栈消息/文档清洗/SKILL.md` | 批量文档格式转换 | | F05 | 代码修复 | 火锤 | 代码修复、Bug | `04_卡火(火)/火锤_代码修复/代码修复/SKILL.md` | 定位 Bug 并修复 | @@ -175,6 +176,6 @@ | 金 | 卡资 | 2 | 21 | | 水 | 卡人 | 3 | 13 | | 木 | 卡木 | 3 | 13 | -| 火 | 卡火 | 4 | 15 | +| 火 | 卡火 | 4 | 16 | | 土 | 卡土 | 4 | 8 | -| **合计** | **5** | **14** | **70** | +| **合计** | **5** | **14** | **71** | diff --git a/运营中枢/scripts/aiye_im_bridge/channels/__init__.py b/运营中枢/scripts/aiye_im_bridge/channels/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/运营中枢/scripts/aiye_im_bridge/channels/feishu.py b/运营中枢/scripts/aiye_im_bridge/channels/feishu.py new file mode 100644 index 00000000..e954eb13 --- /dev/null +++ b/运营中枢/scripts/aiye_im_bridge/channels/feishu.py @@ -0,0 +1,162 @@ +""" +艾叶 IM Bridge — 飞书通道 +对接方式:飞书应用事件订阅(接收 im.message.receive_v1 事件) + +配置项: + app_id: 飞书应用 App ID + app_secret: 飞书应用 App Secret + verification_token: 事件订阅验证 Token + encrypt_key: 事件加密密钥(可选) + +Webhook: POST /webhook/feishu +""" +from __future__ import annotations + +import json +import logging +import time +from typing import Any + +import httpx +from fastapi import Request + +from core.channel_base import ChannelBase, InboundMessage, MessageType, OutboundMessage + +logger = logging.getLogger("aiye.channel.feishu") + + +class FeishuChannel(ChannelBase): + """飞书通道""" + + _tenant_access_token: str = "" + _token_expires: float = 0 + + @property + def platform(self) -> str: + return "feishu" + + async def start(self) -> None: + logger.info("飞书通道已就绪,Webhook: /webhook/feishu") + + async def stop(self) -> None: + logger.info("飞书通道已停止") + + async def _get_tenant_token(self) -> str: + if self._tenant_access_token and time.time() < self._token_expires: + return self._tenant_access_token + app_id = self._config.get("app_id", "") + app_secret = self._config.get("app_secret", "") + if not app_id or not app_secret: + return "" + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", + json={"app_id": app_id, "app_secret": app_secret}, + ) + data = resp.json() + if data.get("code") == 0: + self._tenant_access_token = data["tenant_access_token"] + self._token_expires = time.time() + data.get("expire", 7200) - 300 + return self._tenant_access_token + logger.warning("飞书 token 获取失败: %s", data) + except Exception as e: + logger.error("飞书 token 异常: %s", e) + return "" + + async def send(self, msg: OutboundMessage) -> bool: + token = await self._get_tenant_token() + if not token: + logger.warning("飞书无 tenant_access_token,无法发送") + return False + + receive_id_type = msg.extra.get("receive_id_type", "open_id") + payload = { + "receive_id": msg.chat_id, + "msg_type": "text", + "content": json.dumps({"text": msg.content}, ensure_ascii=False), + } + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={receive_id_type}", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + data = resp.json() + if data.get("code") == 0: + return True + logger.warning("飞书发送失败: %s", data) + except Exception as e: + logger.error("飞书发送异常: %s", e) + return False + + def register_routes(self, app: Any) -> None: + channel = self + _processed_ids: set = set() + + @app.post("/webhook/feishu") + async def feishu_webhook(request: Request): + try: + data = await request.json() + except Exception: + return {"code": 400} + + # URL 验证(飞书后台配置回调时调用) + if data.get("type") == "url_verification": + return {"challenge": data.get("challenge", "")} + + # 事件回调 + header = data.get("header", {}) + event = data.get("event", {}) + event_type = header.get("event_type", "") + + if event_type != "im.message.receive_v1": + return {"code": 0} + + message = event.get("message", {}) + msg_id = message.get("message_id", "") + if msg_id in _processed_ids: + return {"code": 0} + _processed_ids.add(msg_id) + if len(_processed_ids) > 1000: + _processed_ids.clear() + + msg_type = message.get("message_type", "") + sender = event.get("sender", {}).get("sender_id", {}) + open_id = sender.get("open_id", "") + chat_id = message.get("chat_id", "") or open_id + + content_str = message.get("content", "{}") + try: + content_obj = json.loads(content_str) + text = content_obj.get("text", "") + except Exception: + text = content_str + + if msg_type != "text" or not text.strip(): + return {"code": 0} + + # 去掉 @bot 的 mention + mentions = message.get("mentions", []) + for m in mentions: + key = m.get("key", "") + if key: + text = text.replace(key, "").strip() + + inbound = InboundMessage( + channel_id=channel.channel_id, + platform=channel.platform, + sender_id=open_id, + chat_id=chat_id, + content=text, + raw=data, + ) + + reply = await channel.dispatch(inbound) + if reply: + reply.extra["receive_id_type"] = ( + "chat_id" if message.get("chat_type") == "group" else "open_id" + ) + await channel.send(reply) + return {"code": 0} diff --git a/运营中枢/scripts/aiye_im_bridge/channels/web.py b/运营中枢/scripts/aiye_im_bridge/channels/web.py new file mode 100644 index 00000000..366ca7b3 --- /dev/null +++ b/运营中枢/scripts/aiye_im_bridge/channels/web.py @@ -0,0 +1,214 @@ +""" +艾叶 IM Bridge — 个人网页通道 +对接方式: + 1. REST API — POST /api/web/chat + 2. WebSocket — ws://host:port/ws/web/chat + +提供即开即用的网页聊天入口,也可作为第三方系统接入的通用 API。 + +配置项: + allowed_origins: ["*"] # CORS 白名单 +""" +from __future__ import annotations + +import json +import logging +import uuid +from typing import Any, Dict, Set + +from fastapi import Request, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse + +from core.channel_base import ChannelBase, InboundMessage, MessageType, OutboundMessage + +logger = logging.getLogger("aiye.channel.web") + + +class WebChannel(ChannelBase): + """个人网页通道(REST + WebSocket)""" + + _ws_connections: Dict[str, WebSocket] = {} + + @property + def platform(self) -> str: + return "web" + + async def start(self) -> None: + logger.info("网页通道已就绪,API: /api/web/chat | WS: /ws/web/chat") + + async def stop(self) -> None: + for ws in self._ws_connections.values(): + try: + await ws.close() + except Exception: + pass + self._ws_connections.clear() + logger.info("网页通道已停止") + + async def send(self, msg: OutboundMessage) -> bool: + ws = self._ws_connections.get(msg.chat_id) + if ws: + try: + await ws.send_json({"type": "reply", "content": msg.content}) + return True + except Exception as e: + logger.warning("WebSocket 发送失败: %s", e) + return True + + def register_routes(self, app: Any) -> None: + channel = self + + @app.post("/api/web/chat") + async def web_chat_api(request: Request): + """REST 聊天接口:{"message": "你好", "session_id": "可选"}""" + try: + data = await request.json() + except Exception: + return {"code": 400, "msg": "invalid json"} + + message = data.get("message", "").strip() + session_id = data.get("session_id", "") or str(uuid.uuid4())[:8] + if not message: + return {"code": 400, "msg": "empty message"} + + inbound = InboundMessage( + channel_id=channel.channel_id, + platform=channel.platform, + sender_id=session_id, + sender_name=data.get("name", "网页用户"), + chat_id=session_id, + content=message, + ) + + reply = await channel.dispatch(inbound) + return { + "code": 0, + "reply": reply.content if reply else "", + "session_id": session_id, + } + + @app.websocket("/ws/web/chat") + async def web_chat_ws(websocket: WebSocket): + """WebSocket 聊天接口""" + await websocket.accept() + ws_id = str(uuid.uuid4())[:8] + channel._ws_connections[ws_id] = websocket + await websocket.send_json({"type": "connected", "session_id": ws_id}) + logger.info("WebSocket 连接: %s", ws_id) + + try: + while True: + raw = await websocket.receive_text() + try: + data = json.loads(raw) + message = data.get("message", "").strip() + except Exception: + message = raw.strip() + + if not message: + continue + + inbound = InboundMessage( + channel_id=channel.channel_id, + platform=channel.platform, + sender_id=ws_id, + sender_name="网页用户", + chat_id=ws_id, + content=message, + ) + + reply = await channel.dispatch(inbound) + if reply: + await websocket.send_json({ + "type": "reply", + "content": reply.content, + }) + except WebSocketDisconnect: + logger.info("WebSocket 断开: %s", ws_id) + except Exception as e: + logger.warning("WebSocket 异常: %s", e) + finally: + channel._ws_connections.pop(ws_id, None) + + @app.get("/chat", response_class=HTMLResponse) + async def web_chat_page(): + """内嵌网页聊天界面""" + html_path = channel._config.get("html_path", "") + if html_path: + try: + from pathlib import Path + + return Path(html_path).read_text(encoding="utf-8") + except Exception: + pass + return _DEFAULT_CHAT_HTML + + +_DEFAULT_CHAT_HTML = """ + + + + +艾叶 · 卡若AI 聊天 + + + +
艾叶 · 卡若AI
+
+
你好!我是卡若AI,通过艾叶 IM 为你服务。有什么可以帮你的?
+
+
+ + +
+ + +""" diff --git a/运营中枢/scripts/aiye_im_bridge/channels/wechat_personal.py b/运营中枢/scripts/aiye_im_bridge/channels/wechat_personal.py new file mode 100644 index 00000000..977b6440 --- /dev/null +++ b/运营中枢/scripts/aiye_im_bridge/channels/wechat_personal.py @@ -0,0 +1,100 @@ +""" +艾叶 IM Bridge — 个人微信通道 +对接方式:通过 Webhook 回调接收消息(兼容存客宝、WeChatFerry、ComWeChatBot 等中间件)。 +中间件负责微信协议层,艾叶只做消息收发的 HTTP 桥接。 + +接口约定: + - POST /webhook/wechat_personal 接收消息推送 + - 中间件需将消息 POST 到此地址,格式见下方 + - 回复通过中间件的回调 URL 发送 + +消息推送格式(JSON): +{ + "msg_id": "xxx", + "from_user": "wxid_xxx", + "from_name": "昵称", + "to_user": "wxid_yyy", + "room_id": "", // 群聊为群 ID,私聊为空 + "room_name": "", + "content": "你好", + "msg_type": 1, // 1=文本, 3=图片, 34=语音, 43=视频, 49=链接 + "timestamp": 1710000000 +} +""" +from __future__ import annotations + +import logging +from typing import Any + +import httpx +from fastapi import Request + +from core.channel_base import ChannelBase, InboundMessage, MessageType, OutboundMessage + +logger = logging.getLogger("aiye.channel.wechat_personal") + + +class WeChatPersonalChannel(ChannelBase): + """个人微信通道(Webhook 模式)""" + + @property + def platform(self) -> str: + return "wechat_personal" + + async def start(self) -> None: + logger.info("个人微信通道已就绪,等待中间件推送消息到 /webhook/wechat_personal") + + async def stop(self) -> None: + logger.info("个人微信通道已停止") + + async def send(self, msg: OutboundMessage) -> bool: + callback_url = self._config.get("callback_url", "") + if not callback_url: + logger.warning("个人微信通道未配置 callback_url,无法发送回复") + return False + + payload = { + "to_user": msg.chat_id, + "content": msg.content, + "msg_type": "text", + } + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post(callback_url, json=payload) + if resp.status_code == 200: + return True + logger.warning("微信回复发送失败: %d %s", resp.status_code, resp.text[:100]) + except Exception as e: + logger.error("微信回复发送异常: %s", e) + return False + + def register_routes(self, app: Any) -> None: + channel = self + + @app.post("/webhook/wechat_personal") + async def wechat_personal_webhook(request: Request): + try: + data = await request.json() + except Exception: + return {"code": 400, "msg": "invalid json"} + + msg_type_map = {1: MessageType.TEXT, 3: MessageType.IMAGE, 34: MessageType.VOICE} + wx_msg_type = data.get("msg_type", 1) + + inbound = InboundMessage( + channel_id=channel.channel_id, + platform=channel.platform, + sender_id=data.get("from_user", ""), + sender_name=data.get("from_name", ""), + chat_id=data.get("room_id") or data.get("from_user", ""), + chat_name=data.get("room_name", ""), + content=data.get("content", ""), + msg_type=msg_type_map.get(wx_msg_type, MessageType.TEXT), + raw=data, + ) + + reply = await channel.dispatch(inbound) + if reply: + await channel.send(reply) + return {"code": 0, "msg": "ok", "reply": reply.content} + return {"code": 0, "msg": "no reply"} diff --git a/运营中枢/scripts/aiye_im_bridge/channels/wechat_work.py b/运营中枢/scripts/aiye_im_bridge/channels/wechat_work.py new file mode 100644 index 00000000..eb655054 --- /dev/null +++ b/运营中枢/scripts/aiye_im_bridge/channels/wechat_work.py @@ -0,0 +1,154 @@ +""" +艾叶 IM Bridge — 企业微信通道 +对接方式:企业微信应用消息回调(接收消息事件 + 被动回复 / 主动发消息 API) + +配置项: + corp_id: 企业 ID + agent_id: 应用 AgentId + secret: 应用 Secret + token: 回调 Token(用于验签) + encoding_aes_key: 回调 EncodingAESKey(用于解密) + +Webhook: POST /webhook/wechat_work +验证: GET /webhook/wechat_work?echostr=xxx&msg_signature=xxx×tamp=xxx&nonce=xxx +""" +from __future__ import annotations + +import hashlib +import logging +import time +from typing import Any, Optional +from xml.etree import ElementTree + +import httpx +from fastapi import Request, Response + +from core.channel_base import ChannelBase, InboundMessage, MessageType, OutboundMessage + +logger = logging.getLogger("aiye.channel.wechat_work") + + +class WeChatWorkChannel(ChannelBase): + """企业微信通道""" + + _access_token: str = "" + _token_expires: float = 0 + + @property + def platform(self) -> str: + return "wechat_work" + + async def start(self) -> None: + logger.info("企业微信通道已就绪,Webhook: /webhook/wechat_work") + + async def stop(self) -> None: + logger.info("企业微信通道已停止") + + async def _get_access_token(self) -> str: + if self._access_token and time.time() < self._token_expires: + return self._access_token + corp_id = self._config.get("corp_id", "") + secret = self._config.get("secret", "") + if not corp_id or not secret: + return "" + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + "https://qyapi.weixin.qq.com/cgi-bin/gettoken", + params={"corpid": corp_id, "corpsecret": secret}, + ) + data = resp.json() + if data.get("errcode", 0) == 0: + self._access_token = data["access_token"] + self._token_expires = time.time() + data.get("expires_in", 7200) - 300 + return self._access_token + logger.warning("获取企业微信 access_token 失败: %s", data) + except Exception as e: + logger.error("获取企业微信 access_token 异常: %s", e) + return "" + + async def send(self, msg: OutboundMessage) -> bool: + token = await self._get_access_token() + if not token: + logger.warning("企业微信无 access_token,无法发送") + return False + agent_id = self._config.get("agent_id", "") + payload = { + "touser": msg.chat_id, + "msgtype": "text", + "agentid": agent_id, + "text": {"content": msg.content}, + } + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}", + json=payload, + ) + data = resp.json() + if data.get("errcode", 0) == 0: + return True + logger.warning("企业微信发送失败: %s", data) + except Exception as e: + logger.error("企业微信发送异常: %s", e) + return False + + def _verify_signature(self, token: str, timestamp: str, nonce: str, signature: str) -> bool: + cfg_token = self._config.get("token", "") + if not cfg_token: + return True + items = sorted([cfg_token, timestamp, nonce]) + sha1 = hashlib.sha1("".join(items).encode("utf-8")).hexdigest() + return sha1 == signature + + def register_routes(self, app: Any) -> None: + channel = self + + @app.get("/webhook/wechat_work") + async def wechat_work_verify( + msg_signature: str = "", + timestamp: str = "", + nonce: str = "", + echostr: str = "", + ): + """URL 验证(企业微信后台配置回调时调用)""" + if channel._verify_signature( + channel._config.get("token", ""), timestamp, nonce, msg_signature + ): + return Response(content=echostr, media_type="text/plain") + return Response(content="forbidden", status_code=403) + + @app.post("/webhook/wechat_work") + async def wechat_work_webhook(request: Request): + """接收企业微信消息事件""" + body = await request.body() + try: + root = ElementTree.fromstring(body) + msg_type = (root.findtext("MsgType") or "").strip() + from_user = (root.findtext("FromUserName") or "").strip() + content = (root.findtext("Content") or "").strip() + except Exception: + try: + data = await request.json() + msg_type = data.get("MsgType", "text") + from_user = data.get("FromUserName", "") + content = data.get("Content", "") + except Exception: + return Response(content="", media_type="text/xml") + + if msg_type != "text" or not content: + return Response(content="", media_type="text/xml") + + inbound = InboundMessage( + channel_id=channel.channel_id, + platform=channel.platform, + sender_id=from_user, + chat_id=from_user, + content=content, + raw={"body": body.decode("utf-8", errors="replace")}, + ) + + reply = await channel.dispatch(inbound) + if reply: + await channel.send(reply) + return Response(content="", media_type="text/xml") diff --git a/运营中枢/scripts/aiye_im_bridge/channels/whatsapp.py b/运营中枢/scripts/aiye_im_bridge/channels/whatsapp.py new file mode 100644 index 00000000..d63baf4a --- /dev/null +++ b/运营中枢/scripts/aiye_im_bridge/channels/whatsapp.py @@ -0,0 +1,132 @@ +""" +艾叶 IM Bridge — WhatsApp 通道 +对接方式: + 1. WhatsApp Business API (Cloud API) — Webhook 回调 + 2. 可扩展对接 OpenClaw/Moltbot Gateway 的 WebSocket + +当前实现:WhatsApp Cloud API Webhook 模式 + +配置项: + phone_number_id: WhatsApp Business 电话号码 ID + access_token: Meta Graph API 长期令牌 + verify_token: Webhook 验证令牌(自定义字符串) + api_version: Graph API 版本(默认 v21.0) + +Webhook: POST /webhook/whatsapp +验证: GET /webhook/whatsapp +""" +from __future__ import annotations + +import logging +from typing import Any + +import httpx +from fastapi import Request, Response + +from core.channel_base import ChannelBase, InboundMessage, MessageType, OutboundMessage + +logger = logging.getLogger("aiye.channel.whatsapp") + + +class WhatsAppChannel(ChannelBase): + """WhatsApp Cloud API 通道""" + + @property + def platform(self) -> str: + return "whatsapp" + + async def start(self) -> None: + logger.info("WhatsApp 通道已就绪,Webhook: /webhook/whatsapp") + + async def stop(self) -> None: + logger.info("WhatsApp 通道已停止") + + async def send(self, msg: OutboundMessage) -> bool: + phone_id = self._config.get("phone_number_id", "") + token = self._config.get("access_token", "") + api_ver = self._config.get("api_version", "v21.0") + if not phone_id or not token: + logger.warning("WhatsApp 未配置 phone_number_id / access_token") + return False + + url = f"https://graph.facebook.com/{api_ver}/{phone_id}/messages" + payload = { + "messaging_product": "whatsapp", + "to": msg.chat_id, + "type": "text", + "text": {"body": msg.content}, + } + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + url, + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + if resp.status_code == 200: + return True + logger.warning("WhatsApp 发送失败: %d %s", resp.status_code, resp.text[:200]) + except Exception as e: + logger.error("WhatsApp 发送异常: %s", e) + return False + + def register_routes(self, app: Any) -> None: + channel = self + + @app.get("/webhook/whatsapp") + async def whatsapp_verify( + request: Request, + ): + """Meta Webhook 验证""" + params = request.query_params + mode = params.get("hub.mode", "") + token = params.get("hub.verify_token", "") + challenge = params.get("hub.challenge", "") + if mode == "subscribe" and token == channel._config.get("verify_token", ""): + return Response(content=challenge, media_type="text/plain") + return Response(content="forbidden", status_code=403) + + @app.post("/webhook/whatsapp") + async def whatsapp_webhook(request: Request): + try: + data = await request.json() + except Exception: + return {"status": "error"} + + entries = data.get("entry", []) + for entry in entries: + changes = entry.get("changes", []) + for change in changes: + value = change.get("value", {}) + messages = value.get("messages", []) + contacts = value.get("contacts", []) + contact_map = { + c.get("wa_id", ""): c.get("profile", {}).get("name", "") + for c in contacts + } + + for wa_msg in messages: + msg_type = wa_msg.get("type", "") + from_id = wa_msg.get("from", "") + text = "" + if msg_type == "text": + text = wa_msg.get("text", {}).get("body", "") + + if not text.strip(): + continue + + inbound = InboundMessage( + channel_id=channel.channel_id, + platform=channel.platform, + sender_id=from_id, + sender_name=contact_map.get(from_id, ""), + chat_id=from_id, + content=text, + raw=wa_msg, + ) + + reply = await channel.dispatch(inbound) + if reply: + await channel.send(reply) + + return {"status": "ok"} diff --git a/运营中枢/scripts/aiye_im_bridge/config/channels.example.yaml b/运营中枢/scripts/aiye_im_bridge/config/channels.example.yaml new file mode 100644 index 00000000..7457d73d --- /dev/null +++ b/运营中枢/scripts/aiye_im_bridge/config/channels.example.yaml @@ -0,0 +1,70 @@ +# ────────────────────────────────────────────── +# 艾叶 IM Bridge 配置文件 +# 复制为 channels.yaml 后按需修改 +# ────────────────────────────────────────────── + +# 卡若AI 网关连接 +gateway: + url: "http://127.0.0.1:18080" # 卡若AI 网关地址 + api_key: "" # 网关 API Key(若网关启用鉴权) + timeout: 60 # 请求超时秒数 + +# ── 通道配置 ────────────────────────────────── +channels: + + # ╔═══════════════════════════════════╗ + # ║ 个人微信(Webhook 模式) ║ + # ╚═══════════════════════════════════╝ + # 需要微信中间件(存客宝/WeChatFerry/ComWeChatBot)配合 + # 中间件将消息 POST 到 /webhook/wechat_personal + wechat_personal: + enabled: false + # 中间件的回复接口(艾叶把 AI 回复 POST 到这里) + callback_url: "http://127.0.0.1:9000/api/send" + + # ╔═══════════════════════════════════╗ + # ║ 企业微信 ║ + # ╚═══════════════════════════════════╝ + # 在企业微信管理后台创建自建应用,配置回调 URL 为: + # http(s)://your-domain/webhook/wechat_work + wechat_work: + enabled: false + corp_id: "" # 企业 ID + agent_id: "" # 应用 AgentId + secret: "" # 应用 Secret + token: "" # 回调 Token + encoding_aes_key: "" # 回调 EncodingAESKey + + # ╔═══════════════════════════════════╗ + # ║ 飞书 ║ + # ╚═══════════════════════════════════╝ + # 在飞书开放平台创建应用,事件订阅地址: + # http(s)://your-domain/webhook/feishu + # 订阅事件: im.message.receive_v1 + feishu: + enabled: false + app_id: "" # 飞书 App ID + app_secret: "" # 飞书 App Secret + verification_token: "" # 事件订阅验证 Token + encrypt_key: "" # 事件加密密钥(可选) + + # ╔═══════════════════════════════════╗ + # ║ WhatsApp (Cloud API) ║ + # ╚═══════════════════════════════════╝ + # 在 Meta 开发者后台配置 Webhook: + # http(s)://your-domain/webhook/whatsapp + whatsapp: + enabled: false + phone_number_id: "" # WhatsApp Business 电话号码 ID + access_token: "" # Graph API 长期令牌 + verify_token: "aiye_whatsapp_verify" # Webhook 验证令牌 + api_version: "v21.0" + + # ╔═══════════════════════════════════╗ + # ║ 个人网页聊天 ║ + # ╚═══════════════════════════════════╝ + # 默认启用,提供 /chat 网页 + REST API + WebSocket + web: + enabled: true + allowed_origins: ["*"] + # html_path: "" # 自定义聊天页面路径(可选) diff --git a/运营中枢/scripts/aiye_im_bridge/core/__init__.py b/运营中枢/scripts/aiye_im_bridge/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/运营中枢/scripts/aiye_im_bridge/core/bridge.py b/运营中枢/scripts/aiye_im_bridge/core/bridge.py new file mode 100644 index 00000000..a07fc478 --- /dev/null +++ b/运营中枢/scripts/aiye_im_bridge/core/bridge.py @@ -0,0 +1,67 @@ +""" +艾叶 IM Bridge — 网关桥接 +将用户消息通过 HTTP 转发到卡若AI 网关 (/v1/chat),拿到 AI 回复。 +支持带会话上下文的多轮对话。 +""" +from __future__ import annotations + +import logging +import os +from typing import Optional + +import httpx + +from .session import Session + +logger = logging.getLogger("aiye.bridge") + +DEFAULT_GATEWAY_URL = "http://127.0.0.1:18080" +DEFAULT_TIMEOUT = 60 + + +class KaruoGatewayBridge: + """调用卡若AI网关获取 AI 回复。""" + + def __init__(self, gateway_url: str = "", api_key: str = "", timeout: int = 0): + self.gateway_url = ( + gateway_url + or os.environ.get("AIYE_GATEWAY_URL", "").strip() + or DEFAULT_GATEWAY_URL + ).rstrip("/") + self.api_key = api_key or os.environ.get("AIYE_GATEWAY_KEY", "").strip() + self.timeout = timeout or int(os.environ.get("AIYE_GATEWAY_TIMEOUT", str(DEFAULT_TIMEOUT))) + + async def ask(self, prompt: str, session: Optional[Session] = None) -> str: + full_prompt = self._build_prompt(prompt, session) + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["X-Karuo-Api-Key"] = self.api_key + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.post( + f"{self.gateway_url}/v1/chat", + json={"prompt": full_prompt}, + headers=headers, + ) + if resp.status_code == 200: + data = resp.json() + reply = data.get("reply", "") + if reply and session: + session.add_turn(prompt, reply) + return reply or "抱歉,我暂时无法处理你的问题,请稍后再试。" + logger.warning("Gateway returned %d: %s", resp.status_code, resp.text[:200]) + return f"网关返回异常({resp.status_code}),请稍后重试。" + except httpx.TimeoutException: + logger.error("Gateway timeout after %ds", self.timeout) + return "AI 处理超时,请稍后再试或缩短你的问题。" + except Exception as e: + logger.error("Gateway error: %s", e) + return "连接 AI 网关失败,请检查网关是否运行中。" + + @staticmethod + def _build_prompt(user_msg: str, session: Optional[Session]) -> str: + if not session or not session.history: + return user_msg + ctx = session.context_summary() + return f"[对话上下文]\n{ctx}\n\n[当前问题]\n{user_msg}" diff --git a/运营中枢/scripts/aiye_im_bridge/core/channel_base.py b/运营中枢/scripts/aiye_im_bridge/core/channel_base.py new file mode 100644 index 00000000..95b9d59d --- /dev/null +++ b/运营中枢/scripts/aiye_im_bridge/core/channel_base.py @@ -0,0 +1,117 @@ +""" +艾叶 IM Bridge — Channel 基类 +每个通道(微信/飞书/WhatsApp等)继承此类,实现统一的消息收发接口。 +参考 OpenClaw Channel Layer 设计:平台差异在适配器内部消化,对外暴露统一结构。 +""" +from __future__ import annotations + +import abc +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Callable, Coroutine, Optional + + +class MessageType(str, Enum): + TEXT = "text" + IMAGE = "image" + VOICE = "voice" + VIDEO = "video" + FILE = "file" + LOCATION = "location" + LINK = "link" + SYSTEM = "system" + + +@dataclass +class InboundMessage: + """从平台收到的标准化消息(Channel → Router)""" + + channel_id: str + platform: str + sender_id: str + sender_name: str = "" + chat_id: str = "" + chat_name: str = "" + content: str = "" + msg_type: MessageType = MessageType.TEXT + media_url: str = "" + raw: dict = field(default_factory=dict) + timestamp: float = field(default_factory=time.time) + + +@dataclass +class OutboundMessage: + """发往平台的标准化回复(Router → Channel)""" + + channel_id: str + platform: str + chat_id: str + content: str = "" + msg_type: MessageType = MessageType.TEXT + media_url: str = "" + extra: dict = field(default_factory=dict) + + +MessageHandler = Callable[[InboundMessage], Coroutine[Any, Any, Optional[OutboundMessage]]] + + +class ChannelBase(abc.ABC): + """ + 通道抽象基类。 + + 生命周期: + configure(cfg) → start() → [运行中: on_message 回调] → stop() + + 子类必须实现: + - platform (属性) 平台标识 + - start() 启动连接 / 注册 webhook + - stop() 断开 + - send() 发送消息 + - register_routes(app) 注册 FastAPI 路由(webhook 回调) + """ + + def __init__(self, channel_id: str): + self.channel_id = channel_id + self._handler: Optional[MessageHandler] = None + self._config: dict = {} + + @property + @abc.abstractmethod + def platform(self) -> str: + ... + + def configure(self, cfg: dict) -> None: + self._config = cfg + + def on_message(self, handler: MessageHandler) -> None: + self._handler = handler + + async def dispatch(self, msg: InboundMessage) -> Optional[OutboundMessage]: + if self._handler: + return await self._handler(msg) + return None + + @abc.abstractmethod + async def start(self) -> None: + ... + + @abc.abstractmethod + async def stop(self) -> None: + ... + + @abc.abstractmethod + async def send(self, msg: OutboundMessage) -> bool: + ... + + def register_routes(self, app: Any) -> None: + """子类可覆盖,向 FastAPI app 注册 webhook 路由。""" + pass + + @property + def status(self) -> dict: + return { + "channel_id": self.channel_id, + "platform": self.platform, + "configured": bool(self._config), + } diff --git a/运营中枢/scripts/aiye_im_bridge/core/router.py b/运营中枢/scripts/aiye_im_bridge/core/router.py new file mode 100644 index 00000000..7438c8ad --- /dev/null +++ b/运营中枢/scripts/aiye_im_bridge/core/router.py @@ -0,0 +1,80 @@ +""" +艾叶 IM Bridge — 消息路由 +接收所有通道的标准化消息 → 查/建会话 → 调用网关 → 构造回复。 +""" +from __future__ import annotations + +import logging +from typing import Optional + +from .bridge import KaruoGatewayBridge +from .channel_base import InboundMessage, MessageType, OutboundMessage +from .session import SessionManager + +logger = logging.getLogger("aiye.router") + +COMMANDS = { + "/reset": "重置当前对话上下文", + "/status": "查看当前会话状态", + "/help": "查看可用命令", +} + + +class MessageRouter: + """统一消息路由器:Channel → Session → Gateway → Reply""" + + def __init__(self, bridge: KaruoGatewayBridge): + self.sessions = SessionManager() + self.bridge = bridge + + async def handle(self, msg: InboundMessage) -> Optional[OutboundMessage]: + if msg.msg_type != MessageType.TEXT or not msg.content.strip(): + return None + + text = msg.content.strip() + session = self.sessions.get_or_create( + channel_id=msg.channel_id, + platform=msg.platform, + chat_id=msg.chat_id or msg.sender_id, + user_id=msg.sender_id, + user_name=msg.sender_name, + ) + + if text.startswith("/"): + reply = self._handle_command(text, session) + else: + logger.info( + "[%s:%s] %s: %s", + msg.platform, + msg.chat_id or msg.sender_id, + msg.sender_name or msg.sender_id, + text[:80], + ) + reply = await self.bridge.ask(text, session) + + return OutboundMessage( + channel_id=msg.channel_id, + platform=msg.platform, + chat_id=msg.chat_id or msg.sender_id, + content=reply, + ) + + @staticmethod + def _handle_command(text: str, session) -> str: + cmd = text.split()[0].lower() + if cmd == "/reset": + session.reset() + return "对话已重置,可以开始新话题了。" + if cmd == "/status": + return ( + f"平台: {session.platform}\n" + f"会话: {session.session_id}\n" + f"历史轮数: {len(session.history)}\n" + f"用户: {session.user_name or session.user_id}" + ) + if cmd == "/help": + lines = ["艾叶 IM 可用命令:"] + for c, desc in COMMANDS.items(): + lines.append(f" {c} — {desc}") + return "\n".join(lines) + return f"未知命令: {cmd}。输入 /help 查看可用命令。" diff --git a/运营中枢/scripts/aiye_im_bridge/core/session.py b/运营中枢/scripts/aiye_im_bridge/core/session.py new file mode 100644 index 00000000..20c4458c --- /dev/null +++ b/运营中枢/scripts/aiye_im_bridge/core/session.py @@ -0,0 +1,93 @@ +""" +艾叶 IM Bridge — 会话管理 +维护每个用户/群组的对话历史,支持上下文续接与过期清理。 +""" +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Dict, List, Tuple + +# 会话默认 30 分钟过期(无消息则重置上下文) +SESSION_TTL_SECONDS = 1800 +MAX_HISTORY_TURNS = 20 + + +@dataclass +class Session: + session_id: str + channel_id: str + platform: str + chat_id: str + user_id: str + user_name: str = "" + history: List[Tuple[str, str]] = field(default_factory=list) + created_at: float = field(default_factory=time.time) + last_active: float = field(default_factory=time.time) + + @property + def is_expired(self) -> bool: + return (time.time() - self.last_active) > SESSION_TTL_SECONDS + + def add_turn(self, user_msg: str, ai_reply: str) -> None: + self.history.append((user_msg, ai_reply)) + if len(self.history) > MAX_HISTORY_TURNS: + self.history = self.history[-MAX_HISTORY_TURNS:] + self.last_active = time.time() + + def reset(self) -> None: + self.history.clear() + self.last_active = time.time() + + def context_summary(self) -> str: + if not self.history: + return "" + lines = [] + for user_msg, ai_reply in self.history[-5:]: + lines.append(f"用户: {user_msg}") + lines.append(f"AI: {ai_reply}") + return "\n".join(lines) + + +class SessionManager: + """内存级会话池,按 session_id 索引。""" + + def __init__(self) -> None: + self._sessions: Dict[str, Session] = {} + + def _make_id(self, platform: str, chat_id: str, user_id: str) -> str: + return f"{platform}:{chat_id}:{user_id}" + + def get_or_create( + self, + channel_id: str, + platform: str, + chat_id: str, + user_id: str, + user_name: str = "", + ) -> Session: + sid = self._make_id(platform, chat_id, user_id) + session = self._sessions.get(sid) + if session and session.is_expired: + session.reset() + if not session: + session = Session( + session_id=sid, + channel_id=channel_id, + platform=platform, + chat_id=chat_id, + user_id=user_id, + user_name=user_name, + ) + self._sessions[sid] = session + return session + + def cleanup_expired(self) -> int: + expired = [k for k, v in self._sessions.items() if v.is_expired] + for k in expired: + del self._sessions[k] + return len(expired) + + @property + def active_count(self) -> int: + return sum(1 for v in self._sessions.values() if not v.is_expired) diff --git a/运营中枢/scripts/aiye_im_bridge/main.py b/运营中枢/scripts/aiye_im_bridge/main.py new file mode 100644 index 00000000..392b3306 --- /dev/null +++ b/运营中枢/scripts/aiye_im_bridge/main.py @@ -0,0 +1,182 @@ +""" +艾叶 IM Bridge — 主入口 +多平台 IM 消息网关,通过卡若AI网关为所有接入平台提供 AI 对话能力。 + +架构(参考 OpenClaw 三层设计): + 个人微信 / 企业微信 / 飞书 / WhatsApp / 网页 + │ + ┌─────────▼─────────┐ + │ 艾叶 IM Bridge │ ← Channel Layer + │ (FastAPI:18900) │ + └─────────┬─────────┘ + │ HTTP + ┌─────────▼─────────┐ + │ 卡若AI 网关 │ ← LLM Layer + │ (FastAPI:18080) │ + └───────────────────┘ + +启动: + python main.py + # 或 + uvicorn main:app --host 0.0.0.0 --port 18900 +""" +from __future__ import annotations + +import logging +import os +import sys +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any, Dict, List + +import yaml +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse + +from core.bridge import KaruoGatewayBridge +from core.channel_base import ChannelBase +from core.router import MessageRouter + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("aiye") + +CONFIG_PATH = Path(__file__).parent / "config" / "channels.yaml" +DEFAULT_PORT = 18900 + +# ── Channel 注册表 ────────────────────────────────────────── +CHANNEL_REGISTRY: Dict[str, type] = {} + + +def _register_channels() -> None: + from channels.wechat_personal import WeChatPersonalChannel + from channels.wechat_work import WeChatWorkChannel + from channels.feishu import FeishuChannel + from channels.whatsapp import WhatsAppChannel + from channels.web import WebChannel + + CHANNEL_REGISTRY["wechat_personal"] = WeChatPersonalChannel + CHANNEL_REGISTRY["wechat_work"] = WeChatWorkChannel + CHANNEL_REGISTRY["feishu"] = FeishuChannel + CHANNEL_REGISTRY["whatsapp"] = WhatsAppChannel + CHANNEL_REGISTRY["web"] = WebChannel + + +def _load_config() -> Dict[str, Any]: + env_path = os.environ.get("AIYE_CONFIG", "").strip() + p = Path(env_path) if env_path else CONFIG_PATH + if not p.exists(): + logger.warning("配置文件不存在: %s,使用默认配置(仅网页通道)", p) + return {"channels": {"web": {"enabled": True}}} + return yaml.safe_load(p.read_text(encoding="utf-8")) or {} + + +# ── 全局状态 ──────────────────────────────────────────────── +active_channels: List[ChannelBase] = [] +router: MessageRouter | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global router + _register_channels() + cfg = _load_config() + + gateway_cfg = cfg.get("gateway", {}) + bridge = KaruoGatewayBridge( + gateway_url=gateway_cfg.get("url", ""), + api_key=gateway_cfg.get("api_key", ""), + timeout=gateway_cfg.get("timeout", 0), + ) + router = MessageRouter(bridge) + + channels_cfg = cfg.get("channels", {}) + for ch_key, ch_cfg in channels_cfg.items(): + if not isinstance(ch_cfg, dict): + continue + if not ch_cfg.get("enabled", True): + continue + cls = CHANNEL_REGISTRY.get(ch_key) + if not cls: + logger.warning("未知通道: %s,跳过", ch_key) + continue + + channel = cls(channel_id=ch_key) + channel.configure(ch_cfg) + channel.on_message(router.handle) + channel.register_routes(app) + await channel.start() + active_channels.append(channel) + logger.info("✓ 通道已启动: %s (%s)", ch_key, channel.platform) + + logger.info("艾叶 IM Bridge 启动完成,%d 个通道就绪", len(active_channels)) + yield + + for ch in active_channels: + try: + await ch.stop() + except Exception as e: + logger.warning("通道停止异常 %s: %s", ch.channel_id, e) + active_channels.clear() + logger.info("艾叶 IM Bridge 已停止") + + +app = FastAPI( + title="艾叶 IM Bridge", + description="卡若AI 多平台 IM 消息网关 — 让任何聊天平台都能与卡若AI对话", + version="1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ── 管理接口 ──────────────────────────────────────────────── + +@app.get("/", response_class=HTMLResponse) +def index(): + return """ +艾叶 IM Bridge + +

艾叶 IM Bridge

+

卡若AI 多平台 IM 消息网关 — 让任何聊天平台都能与卡若AI对话

+

→ 查看通道状态 | → 网页聊天 | → API 文档

+""" + + +@app.get("/status") +def status(): + return { + "service": "aiye_im_bridge", + "version": "1.0", + "active_channels": [ch.status for ch in active_channels], + "sessions_active": router.sessions.active_count if router else 0, + } + + +@app.get("/health") +def health(): + return {"ok": True, "channels": len(active_channels)} + + +if __name__ == "__main__": + import uvicorn + + port = int(os.environ.get("AIYE_PORT", str(DEFAULT_PORT))) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/运营中枢/scripts/aiye_im_bridge/requirements.txt b/运营中枢/scripts/aiye_im_bridge/requirements.txt new file mode 100644 index 00000000..6d6239ab --- /dev/null +++ b/运营中枢/scripts/aiye_im_bridge/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.115.0 +uvicorn>=0.32.0 +httpx>=0.27.0 +pyyaml>=6.0 +websockets>=13.0 diff --git a/运营中枢/scripts/aiye_im_bridge/start.sh b/运营中枢/scripts/aiye_im_bridge/start.sh new file mode 100755 index 00000000..0193dade --- /dev/null +++ b/运营中枢/scripts/aiye_im_bridge/start.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────── +# 艾叶 IM Bridge 启动脚本 +# 用法: bash start.sh [端口] +# ────────────────────────────────────────────── +set -e +cd "$(dirname "$0")" + +PORT="${1:-18900}" + +if [ ! -d ".venv" ]; then + echo "→ 创建虚拟环境 .venv" + python3 -m venv .venv +fi + +echo "→ 安装依赖" +.venv/bin/pip install -q -r requirements.txt + +if [ ! -f "config/channels.yaml" ]; then + echo "→ 初始化配置文件 config/channels.yaml" + cp config/channels.example.yaml config/channels.yaml +fi + +echo "→ 启动艾叶 IM Bridge (端口 $PORT)" +AIYE_PORT="$PORT" .venv/bin/python main.py diff --git a/运营中枢/工作台/gitea_push_log.md b/运营中枢/工作台/gitea_push_log.md index fcda7562..a63b5792 100644 --- a/运营中枢/工作台/gitea_push_log.md +++ b/运营中枢/工作台/gitea_push_log.md @@ -348,3 +348,4 @@ | 2026-03-13 22:32:29 | 🔄 卡若AI 同步 2026-03-13 22:32 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | | 2026-03-13 22:35:57 | 🔄 卡若AI 同步 2026-03-13 22:35 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | | 2026-03-13 22:39:49 | 🔄 卡若AI 同步 2026-03-13 22:39 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | +| 2026-03-13 22:58:28 | 🔄 卡若AI 同步 2026-03-13 22:58 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | diff --git a/运营中枢/工作台/代码管理.md b/运营中枢/工作台/代码管理.md index bea5086a..00aa1812 100644 --- a/运营中枢/工作台/代码管理.md +++ b/运营中枢/工作台/代码管理.md @@ -351,3 +351,4 @@ | 2026-03-13 22:32:29 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-13 22:32 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | | 2026-03-13 22:35:57 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-13 22:35 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | | 2026-03-13 22:39:49 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-13 22:39 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | +| 2026-03-13 22:58:28 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-13 22:58 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | diff --git a/运营中枢/工作台/项目与端口注册表.md b/运营中枢/工作台/项目与端口注册表.md index b0ad25f9..d1a944bf 100644 --- a/运营中枢/工作台/项目与端口注册表.md +++ b/运营中枢/工作台/项目与端口注册表.md @@ -38,6 +38,8 @@ | Soul 创业实验 | `开发/3、自营项目/一场soul的创业实验-react` | **3000** | **3002** | 开发:`pnpm dev -- -p 3002`(避免与其它占 3000 的冲突);部署:项目目录 `docker compose up -d` | 部署用 3000 时注意仅此项目;开发建议 3002 | | OpenClaw 网关 | 已并入 **website** 编排 | **18789** / **18790** | — | 神射手目录 `docker compose up -d`(镜像需在 OpenClaw 项目内先 build:`openclaw:local`) | 容器名 `website-openclaw-gateway`;配置见 `开发/8、小工具/Docker项目/OpenClaw/openclaw/.env` | | n8n | 已并入 **website** 编排 | **5678** | — | 神射手目录 `docker compose up -d` | 容器名 `website-n8n`;工作流自动化,访问 http://localhost:5678 | +| 艾叶 IM Bridge | `卡若AI/运营中枢/scripts/aiye_im_bridge` | **18900** | **18900** | `bash start.sh` 或 `python main.py` | 多平台 IM 网关(微信/企业微信/飞书/WhatsApp/网页→卡若AI) | +| 卡若AI 网关 | `卡若AI/运营中枢/scripts/karuo_ai_gateway` | **18080** | **18080** | `bash start_local_gateway.sh` | 卡若AI HTTP API 网关 | --- @@ -66,3 +68,4 @@ | 2026-03-01 | 卡若ai网站:数据一律用唯一 MongoDB 27017、库名 karuo_site,不再单独起 27018;全量库设计见项目内 开发文档/7、数据库/全量MongoDB设计_官网与控制台.md | | 2026-03-01 | OpenClaw 网关迁入 website 编排,容器名 website-openclaw-gateway,端口 18789/18790;启动同神射手目录 | | 2026-03-04 | n8n 归入 website 编排,容器名 website-n8n,端口 5678;神射手目录启动 | +| 2026-03-13 | 新增 艾叶 IM Bridge(端口 18900):多平台 IM 网关;卡若AI网关(端口 18080)补登记 |