🔄 卡若AI 同步 2026-03-13 23:06 | 更新:火炬、总索引与入口、运营中枢、运营中枢工作台 | 排除 >20MB: 11 个
This commit is contained in:
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: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 个 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)补登记 |
|
||||
|
||||
Reference in New Issue
Block a user