🔄 卡若AI 同步 2026-03-13 23:06 | 更新:火炬、总索引与入口、运营中枢、运营中枢工作台 | 排除 >20MB: 11 个
This commit is contained in:
172
04_卡火(火)/火炬_全栈消息/艾叶/SKILL.md
Normal file
172
04_卡火(火)/火炬_全栈消息/艾叶/SKILL.md
Normal file
@@ -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网关
|
||||||
|
|
||||||
|
两者可共存,艾叶更适合快速对接新平台、轻量部署。
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# 卡若AI 技能注册表(Skill Registry)
|
# 卡若AI 技能注册表(Skill Registry)
|
||||||
|
|
||||||
> **一张表查所有技能**。任何 AI 拿到这张表,就能按关键词找到对应技能的 SKILL.md 路径并执行。
|
> **一张表查所有技能**。任何 AI 拿到这张表,就能按关键词找到对应技能的 SKILL.md 路径并执行。
|
||||||
> 70 技能 | 14 成员 | 5 负责人
|
> 71 技能 | 14 成员 | 5 负责人
|
||||||
> 版本:5.5 | 更新:2026-03-13
|
> 版本:5.5 | 更新:2026-03-13
|
||||||
>
|
>
|
||||||
> **技能配置、安装、删除、掌管人登记** → 见 **`运营中枢/工作台/01_技能控制台.md`**。
|
> **技能配置、安装、删除、掌管人登记** → 见 **`运营中枢/工作台/01_技能控制台.md`**。
|
||||||
@@ -125,6 +125,7 @@
|
|||||||
| F01a | 前端开发 | 火炬 | **前端开发、毛玻璃、神射手风格、毛狐狸风格、前端标准、苹果毛玻璃** | `04_卡火(火)/火炬_全栈消息/前端开发/SKILL.md` | 苹果毛玻璃风格 + 神射手/毛狐狸前端标准;官网/全站前端走本 Skill |
|
| F01a | 前端开发 | 火炬 | **前端开发、毛玻璃、神射手风格、毛狐狸风格、前端标准、苹果毛玻璃** | `04_卡火(火)/火炬_全栈消息/前端开发/SKILL.md` | 苹果毛玻璃风格 + 神射手/毛狐狸前端标准;官网/全站前端走本 Skill |
|
||||||
| F01b | 全栈测试 | 火炬 | **全栈测试、功能测试、回归测试、深度测试、E2E测试、API测试、发布测试、测试验收** | `04_卡火(火)/火炬_全栈消息/全栈开发/全栈测试/SKILL.md` | 功能开发后系统化验收:前端/后端/数据库/脚本/发布引擎五维测试;**每完成一个功能必须调用** |
|
| F01b | 全栈测试 | 火炬 | **全栈测试、功能测试、回归测试、深度测试、E2E测试、API测试、发布测试、测试验收** | `04_卡火(火)/火炬_全栈消息/全栈开发/全栈测试/SKILL.md` | 功能开发后系统化验收:前端/后端/数据库/脚本/发布引擎五维测试;**每完成一个功能必须调用** |
|
||||||
| F02 | 消息中枢 | 火炬 | WhatsApp、Telegram | `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` | 五行框架拆书 |
|
| F03 | 读书笔记 | 火炬 | 拆解这本书、五行拆书 | `04_卡火(火)/火炬_全栈消息/读书笔记/SKILL.md` | 五行框架拆书 |
|
||||||
| F04 | 文档清洗 | 火炬 | 文档清洗、PDF转MD | `04_卡火(火)/火炬_全栈消息/文档清洗/SKILL.md` | 批量文档格式转换 |
|
| F04 | 文档清洗 | 火炬 | 文档清洗、PDF转MD | `04_卡火(火)/火炬_全栈消息/文档清洗/SKILL.md` | 批量文档格式转换 |
|
||||||
| F05 | 代码修复 | 火锤 | 代码修复、Bug | `04_卡火(火)/火锤_代码修复/代码修复/SKILL.md` | 定位 Bug 并修复 |
|
| F05 | 代码修复 | 火锤 | 代码修复、Bug | `04_卡火(火)/火锤_代码修复/代码修复/SKILL.md` | 定位 Bug 并修复 |
|
||||||
@@ -175,6 +176,6 @@
|
|||||||
| 金 | 卡资 | 2 | 21 |
|
| 金 | 卡资 | 2 | 21 |
|
||||||
| 水 | 卡人 | 3 | 13 |
|
| 水 | 卡人 | 3 | 13 |
|
||||||
| 木 | 卡木 | 3 | 13 |
|
| 木 | 卡木 | 3 | 13 |
|
||||||
| 火 | 卡火 | 4 | 15 |
|
| 火 | 卡火 | 4 | 16 |
|
||||||
| 土 | 卡土 | 4 | 8 |
|
| 土 | 卡土 | 4 | 8 |
|
||||||
| **合计** | **5** | **14** | **70** |
|
| **合计** | **5** | **14** | **71** |
|
||||||
|
|||||||
0
运营中枢/scripts/aiye_im_bridge/channels/__init__.py
Normal file
0
运营中枢/scripts/aiye_im_bridge/channels/__init__.py
Normal file
162
运营中枢/scripts/aiye_im_bridge/channels/feishu.py
Normal file
162
运营中枢/scripts/aiye_im_bridge/channels/feishu.py
Normal file
@@ -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}
|
||||||
214
运营中枢/scripts/aiye_im_bridge/channels/web.py
Normal file
214
运营中枢/scripts/aiye_im_bridge/channels/web.py
Normal file
@@ -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 = """<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>艾叶 · 卡若AI 聊天</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;height:100vh;display:flex;flex-direction:column}
|
||||||
|
.header{background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff;padding:16px 20px;font-size:18px;font-weight:600;text-align:center}
|
||||||
|
.chat-box{flex:1;overflow-y:auto;padding:16px 20px;display:flex;flex-direction:column;gap:12px}
|
||||||
|
.msg{max-width:80%;padding:10px 14px;border-radius:16px;font-size:15px;line-height:1.5;word-break:break-word;animation:fadeIn .3s}
|
||||||
|
.msg.user{align-self:flex-end;background:#667eea;color:#fff;border-bottom-right-radius:4px}
|
||||||
|
.msg.ai{align-self:flex-start;background:#fff;color:#333;border-bottom-left-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.1)}
|
||||||
|
.msg.ai pre{background:#f5f5f5;padding:8px;border-radius:6px;overflow-x:auto;font-size:13px;margin:6px 0}
|
||||||
|
.input-area{display:flex;gap:8px;padding:12px 20px;background:#fff;border-top:1px solid #e0e0e0}
|
||||||
|
.input-area input{flex:1;border:1px solid #ddd;border-radius:20px;padding:10px 16px;font-size:15px;outline:none}
|
||||||
|
.input-area input:focus{border-color:#667eea}
|
||||||
|
.input-area button{background:#667eea;color:#fff;border:none;border-radius:20px;padding:10px 20px;font-size:15px;cursor:pointer}
|
||||||
|
.input-area button:hover{background:#5a6fd6}
|
||||||
|
.typing{align-self:flex-start;color:#999;font-size:13px;padding:4px 14px}
|
||||||
|
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">艾叶 · 卡若AI</div>
|
||||||
|
<div class="chat-box" id="chatBox">
|
||||||
|
<div class="msg ai">你好!我是卡若AI,通过艾叶 IM 为你服务。有什么可以帮你的?</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-area">
|
||||||
|
<input id="msgInput" placeholder="输入消息..." autocomplete="off">
|
||||||
|
<button onclick="sendMsg()">发送</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const chatBox=document.getElementById('chatBox'),input=document.getElementById('msgInput');
|
||||||
|
let ws,sessionId='';
|
||||||
|
function connect(){
|
||||||
|
const proto=location.protocol==='https:'?'wss:':'ws:';
|
||||||
|
ws=new WebSocket(`${proto}//${location.host}/ws/web/chat`);
|
||||||
|
ws.onopen=()=>console.log('connected');
|
||||||
|
ws.onmessage=e=>{
|
||||||
|
const d=JSON.parse(e.data);
|
||||||
|
if(d.type==='connected'){sessionId=d.session_id;return}
|
||||||
|
if(d.type==='reply'){removeTyping();addMsg(d.content,'ai')}
|
||||||
|
};
|
||||||
|
ws.onclose=()=>setTimeout(connect,3000);
|
||||||
|
}
|
||||||
|
function addMsg(text,who){
|
||||||
|
const d=document.createElement('div');d.className='msg '+who;
|
||||||
|
d.innerHTML=who==='ai'?text.replace(/\\n/g,'<br>'):escHtml(text);
|
||||||
|
chatBox.appendChild(d);chatBox.scrollTop=chatBox.scrollHeight;
|
||||||
|
}
|
||||||
|
function showTyping(){
|
||||||
|
if(document.getElementById('typing'))return;
|
||||||
|
const d=document.createElement('div');d.id='typing';d.className='typing';d.textContent='卡若AI 正在思考...';
|
||||||
|
chatBox.appendChild(d);chatBox.scrollTop=chatBox.scrollHeight;
|
||||||
|
}
|
||||||
|
function removeTyping(){const t=document.getElementById('typing');if(t)t.remove()}
|
||||||
|
function escHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||||
|
function sendMsg(){
|
||||||
|
const m=input.value.trim();if(!m||!ws||ws.readyState!==1)return;
|
||||||
|
addMsg(m,'user');ws.send(JSON.stringify({message:m}));input.value='';showTyping();
|
||||||
|
}
|
||||||
|
input.addEventListener('keydown',e=>{if(e.key==='Enter')sendMsg()});
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
100
运营中枢/scripts/aiye_im_bridge/channels/wechat_personal.py
Normal file
100
运营中枢/scripts/aiye_im_bridge/channels/wechat_personal.py
Normal file
@@ -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"}
|
||||||
154
运营中枢/scripts/aiye_im_bridge/channels/wechat_work.py
Normal file
154
运营中枢/scripts/aiye_im_bridge/channels/wechat_work.py
Normal file
@@ -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")
|
||||||
132
运营中枢/scripts/aiye_im_bridge/channels/whatsapp.py
Normal file
132
运营中枢/scripts/aiye_im_bridge/channels/whatsapp.py
Normal file
@@ -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"}
|
||||||
70
运营中枢/scripts/aiye_im_bridge/config/channels.example.yaml
Normal file
70
运营中枢/scripts/aiye_im_bridge/config/channels.example.yaml
Normal file
@@ -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: "" # 自定义聊天页面路径(可选)
|
||||||
0
运营中枢/scripts/aiye_im_bridge/core/__init__.py
Normal file
0
运营中枢/scripts/aiye_im_bridge/core/__init__.py
Normal file
67
运营中枢/scripts/aiye_im_bridge/core/bridge.py
Normal file
67
运营中枢/scripts/aiye_im_bridge/core/bridge.py
Normal file
@@ -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}"
|
||||||
117
运营中枢/scripts/aiye_im_bridge/core/channel_base.py
Normal file
117
运营中枢/scripts/aiye_im_bridge/core/channel_base.py
Normal file
@@ -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),
|
||||||
|
}
|
||||||
80
运营中枢/scripts/aiye_im_bridge/core/router.py
Normal file
80
运营中枢/scripts/aiye_im_bridge/core/router.py
Normal file
@@ -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 查看可用命令。"
|
||||||
93
运营中枢/scripts/aiye_im_bridge/core/session.py
Normal file
93
运营中枢/scripts/aiye_im_bridge/core/session.py
Normal file
@@ -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)
|
||||||
182
运营中枢/scripts/aiye_im_bridge/main.py
Normal file
182
运营中枢/scripts/aiye_im_bridge/main.py
Normal file
@@ -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 """<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><title>艾叶 IM Bridge</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:-apple-system,sans-serif;max-width:720px;margin:40px auto;padding:0 20px;color:#333}
|
||||||
|
h1{background:linear-gradient(135deg,#667eea,#764ba2);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:8px}
|
||||||
|
.badge{display:inline-block;padding:3px 10px;border-radius:12px;font-size:13px;margin:2px}
|
||||||
|
.on{background:#e6f7e6;color:#2d8c2d} .off{background:#f5f5f5;color:#999}
|
||||||
|
a{color:#667eea;text-decoration:none}
|
||||||
|
</style></head><body>
|
||||||
|
<h1>艾叶 IM Bridge</h1>
|
||||||
|
<p>卡若AI 多平台 IM 消息网关 — 让任何聊天平台都能与卡若AI对话</p>
|
||||||
|
<p><a href="/status">→ 查看通道状态</a> | <a href="/chat">→ 网页聊天</a> | <a href="/docs">→ API 文档</a></p>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
5
运营中枢/scripts/aiye_im_bridge/requirements.txt
Normal file
5
运营中枢/scripts/aiye_im_bridge/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn>=0.32.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
pyyaml>=6.0
|
||||||
|
websockets>=13.0
|
||||||
25
运营中枢/scripts/aiye_im_bridge/start.sh
Executable file
25
运营中枢/scripts/aiye_im_bridge/start.sh
Executable file
@@ -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
|
||||||
@@ -348,3 +348,4 @@
|
|||||||
| 2026-03-13 22:32:29 | 🔄 卡若AI 同步 2026-03-13 22:32 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 |
|
| 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: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:39:49 | 🔄 卡若AI 同步 2026-03-13 22:39 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||||
|
| 2026-03-13 22:58:28 | 🔄 卡若AI 同步 2026-03-13 22:58 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||||
|
|||||||
@@ -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: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: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: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) |
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
| Soul 创业实验 | `开发/3、自营项目/一场soul的创业实验-react` | **3000** | **3002** | 开发:`pnpm dev -- -p 3002`(避免与其它占 3000 的冲突);部署:项目目录 `docker compose up -d` | 部署用 3000 时注意仅此项目;开发建议 3002 |
|
| 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` |
|
| 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 |
|
| 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 | 卡若ai网站:数据一律用唯一 MongoDB 27017、库名 karuo_site,不再单独起 27018;全量库设计见项目内 开发文档/7、数据库/全量MongoDB设计_官网与控制台.md |
|
||||||
| 2026-03-01 | OpenClaw 网关迁入 website 编排,容器名 website-openclaw-gateway,端口 18789/18790;启动同神射手目录 |
|
| 2026-03-01 | OpenClaw 网关迁入 website 编排,容器名 website-openclaw-gateway,端口 18789/18790;启动同神射手目录 |
|
||||||
| 2026-03-04 | n8n 归入 website 编排,容器名 website-n8n,端口 5678;神射手目录启动 |
|
| 2026-03-04 | n8n 归入 website 编排,容器名 website-n8n,端口 5678;神射手目录启动 |
|
||||||
|
| 2026-03-13 | 新增 艾叶 IM Bridge(端口 18900):多平台 IM 网关;卡若AI网关(端口 18080)补登记 |
|
||||||
|
|||||||
Reference in New Issue
Block a user