🔄 卡若AI 同步 2026-03-13 23:06 | 更新:火炬、总索引与入口、运营中枢、运营中枢工作台 | 排除 >20MB: 11 个

This commit is contained in:
2026-03-13 23:06:19 +08:00
parent 7885fa27c5
commit d52f030646
20 changed files with 1582 additions and 3 deletions

View 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}

View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
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>"""

View 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"}

View 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&timestamp=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")

View 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"}

View 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: "" # 自定义聊天页面路径(可选)

View 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}"

View 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),
}

View 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 查看可用命令。"

View 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)

View 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)

View File

@@ -0,0 +1,5 @@
fastapi>=0.115.0
uvicorn>=0.32.0
httpx>=0.27.0
pyyaml>=6.0
websockets>=13.0

View 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

View File

@@ -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 个 |

View File

@@ -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) |

View File

@@ -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补登记 |