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,通过艾叶 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)补登记 |