Files

1571 lines
59 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""企业微信频道模块
基于 WebSocket 长连接的企业微信机器人实现。
支持流式回复和实时消息处理。
"""
import asyncio
import base64
import hashlib
import json
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional, Dict, List, Callable
import threading
import re
import httpx
from loguru import logger
from backend.modules.channels.base import BaseChannel, InboundMessage, OutboundMessage
from backend.modules.channels.media_utils import (
decrypt_wecom_media_bytes,
extract_filename_from_content_disposition,
format_inbound_media_text,
save_bytes_to_temp,
)
try:
import websockets
import websockets.protocol
WEBSOCKETS_AVAILABLE = True
except ImportError:
WEBSOCKETS_AVAILABLE = False
websockets = None
# 长连接命令常量
class LongConnCmd:
SUBSCRIBE = "aibot_subscribe"
PING = "ping"
MSG_CALLBACK = "aibot_msg_callback"
EVENT_CALLBACK = "aibot_event_callback"
RESPOND_WELCOME_MSG = "aibot_respond_welcome_msg"
RESPOND_MSG = "aibot_respond_msg"
RESPOND_UPDATE_MSG = "aibot_respond_update_msg"
SEND_MSG = "aibot_send_msg"
# 长连接错误类
class LongConnError(Exception):
"""长连接基础错误"""
pass
class LongConnPermanentError(LongConnError):
"""不可恢复的长连接错误(如认证失败)"""
pass
class LongConnAPIError(LongConnError):
"""企业微信 API 返回的业务错误"""
def __init__(self, cmd: str, request_id: str, err_code: int, err_msg: str):
self.cmd = cmd
self.request_id = request_id
self.err_code = err_code
self.err_msg = err_msg
super().__init__(f"longconn api error: cmd={cmd} req_id={request_id} errcode={err_code} errmsg={err_msg}")
@dataclass
class StreamState:
"""流式回复状态管理"""
stream_id: str
accumulated_text: str = ""
reasoning_text: str = ""
progress_lines: List[str] = field(default_factory=list)
last_send_time: float = 0
message_count: int = 0
finished: bool = False
request_id: str = ""
@dataclass
class LongConnRequest:
"""长连接请求帧"""
cmd: str
headers: Dict[str, str]
body: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
result = {
"cmd": self.cmd,
"headers": self.headers
}
if self.body is not None:
result["body"] = self.body
return result
@dataclass
class LongConnResponse:
"""长连接响应帧"""
headers: Dict[str, str]
err_code: int = 0
err_msg: str = ""
@dataclass
class LongConnFrame:
"""长连接原始帧"""
cmd: Optional[str] = None
headers: Optional[Dict[str, str]] = None
body: Optional[Dict[str, Any]] = None
err_code: Optional[int] = None
err_msg: Optional[str] = None
def has_ack_result(self) -> bool:
"""判断是否是命令响应帧"""
return self.err_code is not None or self.err_msg
def is_callback(self) -> bool:
"""判断是否是回调帧"""
return (self.cmd in [LongConnCmd.MSG_CALLBACK, LongConnCmd.EVENT_CALLBACK]
and self.body is not None)
def normalize_thinking_tags(text: str) -> str:
"""标准化思考标签"""
if not text:
return ""
# 处理 <think> 标签
text = re.sub(r'<think>(.*?)</think>', r'\1', text, flags=re.DOTALL)
return text.strip()
def build_stream_content(reasoning_text: str = "", visible_text: str = "", finish: bool = False) -> str:
"""构建流式内容"""
normalized_reasoning = str(reasoning_text or "").strip()
normalized_visible = str(visible_text or "").strip()
if not normalized_reasoning:
return normalized_visible
should_close_think = finish or bool(normalized_visible)
think_block = f"<think>{normalized_reasoning}</think>" if should_close_think else f"<think>{normalized_reasoning}"
return f"{think_block}\n{normalized_visible}" if normalized_visible else think_block
_THINK_TAG_RE = re.compile(r"<\s*(/?)\s*(?:think(?:ing)?|thought)\b[^<>]*>", re.IGNORECASE)
_FAST_COMMAND_EXACT = {
"/new",
"/newsession",
"/new_session",
"/n",
"/list",
"/sessions",
"/list_sessions",
"/l",
"/ls",
"/all",
"/all_sessions",
"/al",
"/clear",
"/clear_history",
"/c",
"/stop",
"/cancel",
"/help",
"/h",
"/?",
"/provider",
"/m",
"/personality",
"/p",
"/team",
}
_FAST_COMMAND_PREFIXES = (
"/switch ",
"/s ",
"/route",
"/rt",
"/coder",
"/cdr",
"/provider ",
"/m ",
"/personality ",
"/p ",
)
def normalize_thinking_tags(text: str) -> str:
"""Normalize think tag variants to the canonical WeCom form."""
if not text:
return ""
normalized = _THINK_TAG_RE.sub(
lambda match: "</think>" if match.group(1) else "<think>",
str(text),
)
return normalized.strip()
def is_fast_command_text(text: str) -> bool:
normalized = str(text or "").strip().lower()
if not normalized:
return False
if normalized in _FAST_COMMAND_EXACT:
return True
return normalized.startswith(_FAST_COMMAND_PREFIXES)
def truncate_progress_text(value: Any, limit: int = 80) -> str:
text = str(value or "").strip().replace("\n", " ")
if len(text) > limit:
return text[: limit - 3] + "..."
return text
def build_tool_progress_line(event_type: str, payload: Dict[str, Any]) -> Optional[str]:
if event_type == "tool_call":
tool_name = truncate_progress_text(payload.get("tool_name") or "unknown")
return f"调用工具:`{tool_name}`"
if event_type == "tool_result":
tool_name = truncate_progress_text(payload.get("tool_name") or "unknown")
return f"工具完成:`{tool_name}`"
if event_type == "tool_error":
tool_name = truncate_progress_text(payload.get("tool_name") or "unknown")
return f"工具失败:`{tool_name}`"
if event_type == "tool_progress":
tool_name = truncate_progress_text(payload.get("tool_name") or "unknown")
message = truncate_progress_text(payload.get("message") or "仍在运行")
return f"`{tool_name}` {message}"
if event_type == "workflow_agent_start":
label = truncate_progress_text(
payload.get("agent_label") or payload.get("agent_id") or "阶段"
)
return f"阶段开始:`{label}`"
if event_type == "workflow_agent_tool_call":
label = truncate_progress_text(
payload.get("agent_label") or payload.get("agent_id") or "阶段"
)
tool_name = truncate_progress_text(payload.get("tool") or "unknown")
return f"`{label}` 调用工具:`{tool_name}`"
if event_type == "workflow_agent_complete":
label = truncate_progress_text(
payload.get("agent_label") or payload.get("agent_id") or "阶段"
)
return f"阶段完成:`{label}`"
return None
def render_progress_text(lines: List[str]) -> str:
body = "\n".join(f"- {line}" for line in lines[-8:])
return f"处理中...\n\n{body}" if body else ""
def resolve_stream_visible_text(state: StreamState, *, include_progress: bool) -> str:
visible_text = str(state.accumulated_text or "").strip()
if visible_text:
return visible_text
if include_progress:
return render_progress_text(state.progress_lines)
return ""
MAX_REPLY_MSG_ITEMS = 10
MAX_REPLY_IMAGE_BYTES = 10 * 1024 * 1024
SUPPORTED_REPLY_IMAGE_SIGNATURES = {
"png": bytes.fromhex("89504e470d0a1a0a"),
"jpg": bytes.fromhex("ffd8ff"),
}
REPLY_MEDIA_DIRECTIVE_PATTERN = re.compile(
r"^\s*(?:[-*•]\s+|\d+\.\s+)?MEDIA\s*:\s*(.+?)\s*$",
re.IGNORECASE,
)
class LongConnBot:
"""企业微信长连接机器人"""
def __init__(self, bot_id: str, secret: str, handler: Optional[Callable] = None,
websocket_url: str = "wss://openws.work.weixin.qq.com"):
if not bot_id:
raise ValueError("bot_id is required")
if not secret:
raise ValueError("secret is required")
self.bot_id = bot_id
self.secret = secret
self.handler = handler
# 连接配置
self.ws_url = websocket_url
self.ping_interval = 30 # 秒
self.reconnect_interval = 3 # 秒
self.request_timeout = 10 # 秒
self.write_timeout = 5 # 秒
# 流式回复配置
self.stream_throttle_ms = 800 # 流式更新节流间隔(毫秒)
self.max_intermediate_messages = 85 # 最大中间消息数
self.thinking_message = "思考中..." # 思考提示消息
# 连接状态
self.conn_lock = threading.RLock()
self.conn: Optional[websockets.WebSocketClientProtocol] = None
self.write_lock = asyncio.Lock()
# 请求管理
self.pending_lock = threading.Lock()
self.pending: Dict[str, asyncio.Future] = {}
# 流式状态管理
self.stream_states: Dict[str, StreamState] = {} # message_id -> StreamState
self.stream_lock = threading.Lock()
# 控制标志
self.close_once = threading.Lock()
self.closed_event = asyncio.Event()
self.running = False
def generate_request_id(self) -> str:
"""生成请求ID"""
return f"req_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}"
def set_stream_state(self, message_id: str, state: StreamState) -> None:
"""设置流式状态"""
with self.stream_lock:
self.stream_states[message_id] = state
def get_stream_state(self, message_id: str) -> Optional[StreamState]:
"""获取流式状态"""
with self.stream_lock:
return self.stream_states.get(message_id)
def delete_stream_state(self, message_id: str) -> None:
"""删除流式状态"""
with self.stream_lock:
self.stream_states.pop(message_id, None)
def can_send_intermediate(self, state: StreamState) -> bool:
"""检查是否可以发送中间消息"""
return state.message_count < self.max_intermediate_messages
def should_throttle_update(self, state: StreamState) -> bool:
"""检查是否应该节流更新"""
elapsed = (time.time() * 1000) - state.last_send_time
return elapsed < self.stream_throttle_ms
async def send_stream_reply(
self,
frame: LongConnFrame,
stream_id: str,
text: str,
finish: bool = False,
msg_items: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""发送流式回复"""
normalized_text = normalize_thinking_tags(text)
if not normalized_text and not finish and not msg_items:
return
conn = self._current_conn()
if not conn:
raise LongConnError("WebSocket not connected")
# 基于官方 SDK 的流式回复格式
reply_body = {
"msgtype": "stream",
"stream": {
"id": stream_id,
"finish": finish,
"content": normalized_text
}
}
if msg_items:
reply_body["stream"]["msg_item"] = msg_items
request = LongConnRequest(
cmd=LongConnCmd.RESPOND_MSG,
headers={"req_id": frame.headers.get("req_id", "")},
body=reply_body
)
req_id = frame.headers.get("req_id", "")
wait_for_ack = finish or bool(msg_items)
if wait_for_ack and req_id:
response = await self._send_request_and_wait(
LongConnCmd.RESPOND_MSG,
req_id,
reply_body,
)
logger.info(
f"[LongConnBot] Stream reply acked finish={finish} "
f"msg_items={len(msg_items or [])} err_code={response.err_code} err_msg={response.err_msg}"
)
else:
await self._write_json(conn, request.to_dict())
logger.debug(
f"[LongConnBot] → Stream reply sent cmd={request.cmd} finish={finish} "
f"msg_items={len(msg_items or [])}: {normalized_text[:50]}..."
)
async def send_thinking_reply(self, frame: LongConnFrame, stream_id: str) -> None:
"""发送思考提示"""
try:
await self.send_stream_reply(frame, stream_id, self.thinking_message, finish=False)
except Exception as e:
logger.error(f"[LongConnBot] Failed to send thinking reply: {e}")
@staticmethod
def _detect_reply_image_format(image_bytes: bytes) -> Optional[str]:
"""检测企业微信被动回复支持的图片格式。"""
if image_bytes.startswith(SUPPORTED_REPLY_IMAGE_SIGNATURES["png"]):
return "png"
if image_bytes.startswith(SUPPORTED_REPLY_IMAGE_SIGNATURES["jpg"]):
return "jpg"
return None
@staticmethod
def _split_reply_media_from_text(text: str) -> tuple[str, List[str]]:
"""从最终回复文本中提取 MEDIA:/abs/path 指令。"""
if not text:
return "", []
media_paths: List[str] = []
kept_lines: List[str] = []
for line in text.splitlines():
match = REPLY_MEDIA_DIRECTIVE_PATTERN.match(line)
if not match:
kept_lines.append(line)
continue
media_path = match.group(1).strip().strip("`").strip()
if media_path:
media_paths.append(media_path)
cleaned_text = "\n".join(kept_lines)
cleaned_text = re.sub(r"\n{3,}", "\n\n", cleaned_text).strip()
return cleaned_text, media_paths
def _build_reply_image_msg_items(self, media_paths: List[str]) -> List[Dict[str, Any]]:
"""构建企业微信长连接最终帧图片 msg_item。"""
msg_items: List[Dict[str, Any]] = []
seen: set[str] = set()
for media_path in media_paths:
normalized_path = str(media_path).strip()
if not normalized_path or normalized_path in seen:
continue
seen.add(normalized_path)
if len(msg_items) >= MAX_REPLY_MSG_ITEMS:
logger.warning(
f"[LongConnBot] Reply image count exceeds {MAX_REPLY_MSG_ITEMS}, remaining images skipped"
)
break
path = Path(normalized_path)
if not path.is_file():
logger.warning(f"[LongConnBot] Reply image not found: {normalized_path}")
continue
image_bytes = path.read_bytes()
if len(image_bytes) > MAX_REPLY_IMAGE_BYTES:
logger.warning(f"[LongConnBot] Reply image too large ({len(image_bytes)} bytes): {normalized_path}")
continue
image_format = self._detect_reply_image_format(image_bytes)
if not image_format:
logger.warning(
f"[LongConnBot] Reply media format is not supported by WeCom passive reply: {normalized_path}"
)
continue
md5_value = hashlib.md5(image_bytes).hexdigest()
logger.info(
f"[LongConnBot] Reply image prepared path={normalized_path} format={image_format} "
f"bytes={len(image_bytes)} md5={md5_value}"
)
msg_items.append(
{
"msgtype": "image",
"image": {
"base64": base64.b64encode(image_bytes).decode("utf-8"),
"md5": md5_value,
},
}
)
return msg_items
def prepare_reply_payload(
self,
text: str,
metadata: Optional[Dict[str, Any]] = None,
) -> tuple[str, List[Dict[str, Any]]]:
metadata = metadata or {}
content_to_send, directive_media_paths = self._split_reply_media_from_text(text)
pending_media_paths = metadata.get("_wecom_pending_media_paths", []) or []
pending_media_text = str(metadata.get("_wecom_pending_media_text", "") or "").strip()
reply_media_paths = [*pending_media_paths, *directive_media_paths]
msg_items = self._build_reply_image_msg_items(reply_media_paths)
if pending_media_text and not content_to_send:
content_to_send = pending_media_text
elif not content_to_send and msg_items:
content_to_send = "已发送图片,请查看。"
return content_to_send, msg_items
async def start(self, ctx: Optional[asyncio.Event] = None) -> None:
"""启动长连接机器人"""
if self.running:
return
self.running = True
logger.info(f"[LongConnBot] Starting bot {self.bot_id[:12]}...")
try:
while self.running and not self.closed_event.is_set():
# 检查上下文取消
if ctx and ctx.is_set():
break
try:
await self._run_session(ctx)
break # 正常退出
except LongConnPermanentError as e:
logger.error(f"[LongConnBot] Permanent error: {e}")
raise e
except Exception as e:
logger.warning(f"[LongConnBot] Session error: {e}, reconnecting in {self.reconnect_interval}s...")
# 等待重连间隔
try:
await asyncio.wait_for(self.closed_event.wait(), timeout=self.reconnect_interval)
break # 被主动关闭
except asyncio.TimeoutError:
continue # 继续重连
finally:
self.running = False
await self._cleanup()
async def _run_session(self, ctx: Optional[asyncio.Event] = None) -> None:
"""运行一次完整的长连接会话"""
# 建立 WebSocket 连接
conn = await websockets.connect(
self.ws_url,
ping_interval=None, # 使用自定义心跳
close_timeout=10
)
read_task: Optional[asyncio.Task] = None
ping_task: Optional[asyncio.Task] = None
try:
# 设置当前连接
self._set_conn(conn)
logger.info(f"[LongConnBot] WebSocket connected")
# 启动读循环
read_task = asyncio.create_task(self._read_loop(conn))
# 发送订阅命令
await self._subscribe()
logger.success(f"[LongConnBot] Subscribed successfully")
# 启动心跳循环
ping_task = asyncio.create_task(self._ping_loop(ctx))
# 等待任一任务完成
done, pending = await asyncio.wait(
[read_task, ping_task],
return_when=asyncio.FIRST_COMPLETED
)
# 取消未完成的任务
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# 检查是否有异常
for task in done:
if task.exception():
raise task.exception()
finally:
for task in (ping_task, read_task):
if task and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, websockets.exceptions.ConnectionClosed, websockets.exceptions.ConnectionClosedError):
pass
except Exception as e:
logger.debug(f"[LongConnBot] Background task cleanup error: {e}")
self._release_conn(conn, Exception("session closed"))
async def _subscribe(self) -> None:
"""发送订阅命令"""
request_id = self.generate_request_id()
body = {
"bot_id": self.bot_id,
"secret": self.secret
}
await self._send_request_and_wait(LongConnCmd.SUBSCRIBE, request_id, body)
async def _read_loop(self, conn: websockets.WebSocketClientProtocol) -> None:
"""读取消息循环"""
try:
async for message in conn:
await self._handle_raw_message(message)
except websockets.exceptions.ConnectionClosed as e:
if self.running and not self.closed_event.is_set():
logger.info(f"[LongConnBot] WebSocket closed: {e.code} {e.reason}")
else:
logger.debug(f"[LongConnBot] WebSocket closed during shutdown: {e.code}")
raise
except asyncio.CancelledError:
logger.debug("[LongConnBot] Read loop cancelled")
raise
except Exception as e:
if self.running:
logger.error(f"[LongConnBot] Read loop error: {e}")
else:
logger.debug(f"[LongConnBot] Read loop error during shutdown: {e}")
raise
async def _ping_loop(self, ctx: Optional[asyncio.Event] = None) -> None:
"""心跳循环"""
try:
while self.running and not self.closed_event.is_set():
# 检查上下文取消
if ctx and ctx.is_set():
break
try:
await asyncio.sleep(self.ping_interval)
except asyncio.CancelledError:
logger.debug("[LongConnBot] Ping loop cancelled during sleep")
break
# 再次检查状态
if not self.running or self.closed_event.is_set():
break
# 发送心跳
try:
request_id = self.generate_request_id()
await self._send_request_and_wait(LongConnCmd.PING, request_id, None)
logger.debug(f"[LongConnBot] Ping sent")
except (websockets.exceptions.ConnectionClosed, asyncio.CancelledError):
logger.debug("[LongConnBot] Ping failed, connection closed")
break
except Exception as e:
if self.running:
logger.warning(f"[LongConnBot] Ping failed: {e}")
break
except asyncio.CancelledError:
logger.debug("[LongConnBot] Ping loop cancelled")
except Exception as e:
if self.running and not self.closed_event.is_set():
logger.error(f"[LongConnBot] Ping loop error: {e}")
else:
logger.debug(f"[LongConnBot] Ping loop error during shutdown: {e}")
async def _handle_raw_message(self, raw_message: str) -> None:
"""处理原始消息"""
try:
data = json.loads(raw_message)
frame = LongConnFrame(
cmd=data.get("cmd"),
headers=data.get("headers", {}),
body=data.get("body"),
err_code=data.get("errcode"),
err_msg=data.get("errmsg", "")
)
logger.debug(f"[LongConnBot] Received frame: cmd={frame.cmd}")
# 处理命令响应
if frame.has_ack_result():
request_id = frame.headers.get("req_id", "")
if request_id and self._complete_pending(request_id, frame):
return
# 处理回调消息
if frame.is_callback():
await self._handle_callback(frame)
except json.JSONDecodeError as e:
logger.warning(f"[LongConnBot] Failed to parse message: {e}")
except Exception as e:
logger.error(f"[LongConnBot] Error handling message: {e}")
async def _handle_callback(self, frame: LongConnFrame) -> None:
"""处理回调消息"""
if not self.handler:
return
try:
# 根据回调类型处理
if frame.cmd == LongConnCmd.MSG_CALLBACK:
await self._handle_message_callback(frame)
elif frame.cmd == LongConnCmd.EVENT_CALLBACK:
await self._handle_event_callback(frame)
except Exception as e:
logger.error(f"[LongConnBot] Callback handler error: {e}")
async def _handle_message_callback(self, frame: LongConnFrame) -> None:
"""处理消息回调"""
if not frame.body:
return
# 提取消息信息
sender_info = frame.body.get("from", {}) or {}
sender_id = sender_info.get("userid", "")
sender_name = sender_info.get("name") or sender_id
chat_id = frame.body.get("chatid", sender_id)
msg_type = frame.body.get("msgtype", "")
message_id = frame.body.get("msgid", "")
# 解析消息内容
content = ""
if msg_type == "text":
content = frame.body.get("text", {}).get("content", "")
elif msg_type == "voice":
content = frame.body.get("voice", {}).get("content", "")
elif msg_type == "mixed":
# 图文混排
text_parts = []
msg_items = frame.body.get("mixed", {}).get("msg_item", [])
for item in msg_items:
if item.get("msgtype") == "text":
text_parts.append(item.get("text", {}).get("content", ""))
content = "\n".join(text_parts)
if not content and msg_type not in {"image", "file", "mixed"}:
return
preview = content or f"[{msg_type}]"
logger.info(f"[LongConnBot] ← Message from {sender_id}: {preview[:50]}...")
# 初始化流式状态
stream_id = self.generate_request_id()
request_id = frame.headers.get("req_id", "")
stream_state = StreamState(
stream_id=stream_id,
request_id=request_id
)
self.set_stream_state(message_id, stream_state)
metadata = frame.body.copy()
metadata["sender_name"] = sender_name
metadata["_wecom_stream_message_id"] = message_id
metadata["_wecom_frame_headers"] = dict(frame.headers or {})
# 发送思考提示
if not is_fast_command_text(content):
await self.send_thinking_reply(frame, stream_id)
if self.handler:
try:
async def push_progress_line(line: str) -> None:
if not line:
return
state = self.get_stream_state(message_id)
if not state or state.finished:
return
if state.progress_lines and state.progress_lines[-1] == line:
return
state.progress_lines.append(line)
if len(state.progress_lines) > 8:
state.progress_lines = state.progress_lines[-8:]
if state.accumulated_text.strip():
return
if not self.can_send_intermediate(state):
return
if self.should_throttle_update(state):
return
content_to_send = build_stream_content(
reasoning_text=state.reasoning_text,
visible_text=resolve_stream_visible_text(state, include_progress=True),
finish=False,
)
await self.send_stream_reply(
frame,
state.stream_id,
content_to_send,
finish=False,
)
state.last_send_time = time.time() * 1000
state.message_count += 1
async def tool_event_handler(event_type: str, payload: Dict[str, Any]) -> None:
line = build_tool_progress_line(event_type, payload or {})
if line:
await push_progress_line(line)
async def abort_stream_handler() -> None:
state = self.get_stream_state(message_id)
if not state or state.finished:
return
state.finished = True
await self.send_stream_reply(
frame,
state.stream_id,
"已停止生成。",
finish=True,
)
self.delete_stream_state(message_id)
async def stream_handler(text_chunk: str, is_final: bool = False, is_reasoning: bool = False):
"""流式处理器:累积文本并发送到企业微信"""
state = self.get_stream_state(message_id)
if not state:
logger.warning(f"[LongConnBot] Stream state not found for message {message_id}")
return
# 累积文本
if is_reasoning:
state.reasoning_text += text_chunk
else:
state.accumulated_text += text_chunk
logger.debug(f"[LongConnBot] Stream: chunk={len(text_chunk)}, final={is_final}, total={len(state.accumulated_text)}")
# 最终消息:发送完整内容
if is_final:
content_to_send = build_stream_content(
reasoning_text=state.reasoning_text,
visible_text=resolve_stream_visible_text(state, include_progress=False),
finish=True
)
content_to_send, msg_items = self.prepare_reply_payload(content_to_send, metadata)
if not content_to_send and state.progress_lines and not msg_items:
content_to_send = "处理完成。"
logger.info(
f"[LongConnBot] Final reply: {len(content_to_send)} chars, "
f"reply_images={len(msg_items)}"
)
await self.send_stream_reply(
frame,
state.stream_id,
content_to_send,
finish=True,
msg_items=msg_items or None,
)
state.finished = True
self.delete_stream_state(message_id)
return
# 中间更新:检查节流
if not self.can_send_intermediate(state):
return
if self.should_throttle_update(state):
return
# 发送中间更新
content_to_send = build_stream_content(
reasoning_text=state.reasoning_text,
visible_text=resolve_stream_visible_text(state, include_progress=True),
finish=False
)
await self.send_stream_reply(
frame,
state.stream_id,
content_to_send,
finish=False,
)
state.last_send_time = time.time() * 1000
state.message_count += 1
# 通过 metadata 传递流式处理器
metadata['_stream_handler'] = stream_handler
metadata["_tool_event_handler"] = tool_event_handler
metadata["_stream_abort_handler"] = abort_stream_handler
await self.handler(sender_id, chat_id, content, metadata)
except Exception as e:
logger.error(f"[LongConnBot] Handler error: {e}")
state = self.get_stream_state(message_id)
if state:
await self.send_stream_reply(
frame,
state.stream_id,
"处理消息时发生错误,请稍后重试。",
finish=True,
)
self.delete_stream_state(message_id)
async def _handle_event_callback(self, frame: LongConnFrame) -> None:
"""处理事件回调"""
if not frame.body:
return
event_type = frame.body.get("event", {}).get("event_type", "")
logger.info(f"[LongConnBot] Event: {event_type}")
# 处理进入聊天事件
if event_type == "enter_chat":
welcome_msg = {
"msgtype": "text",
"text": {"content": "你好,我是 AI 助手。"}
}
await self._send_callback_command(LongConnCmd.RESPOND_WELCOME_MSG, frame.headers.get("req_id", ""), welcome_msg)
async def _send_callback_command(self, command: str, request_id: str, body: Any) -> None:
"""发送回调命令 - 不等待响应避免超时"""
try:
conn = self._current_conn()
if not conn:
raise LongConnError("WebSocket not connected")
request = LongConnRequest(
cmd=command,
headers={"req_id": request_id},
body=body
)
await self._write_json(conn, request.to_dict())
logger.debug(f"[LongConnBot] → Callback command sent: {command}")
except Exception as e:
logger.error(f"[LongConnBot] Failed to send callback command {command}: {e}")
raise
async def _send_request_and_wait(self, command: str, request_id: str, body: Any) -> LongConnResponse:
"""发送请求并等待响应"""
if not request_id:
raise ValueError("request_id is required")
conn = self._current_conn()
if not conn:
raise LongConnError("WebSocket not connected")
# 注册等待器
future = asyncio.Future()
with self.pending_lock:
self.pending[request_id] = future
try:
# 发送请求
request = LongConnRequest(
cmd=command,
headers={"req_id": request_id},
body=body
)
await self._write_json(conn, request.to_dict())
# 等待响应
try:
frame = await asyncio.wait_for(future, timeout=self.request_timeout)
# 检查错误
if frame.err_code and frame.err_code != 0:
if command == LongConnCmd.SUBSCRIBE and frame.err_code in [40001, 40014, 93019]:
# 认证失败,永久错误
raise LongConnPermanentError(f"Authentication failed: {frame.err_code} {frame.err_msg}")
else:
raise LongConnAPIError(command, request_id, frame.err_code, frame.err_msg)
return LongConnResponse(
headers=frame.headers or {},
err_code=frame.err_code or 0,
err_msg=frame.err_msg or ""
)
except asyncio.TimeoutError:
raise LongConnError(f"Request timeout: {command}")
finally:
# 清理等待器
with self.pending_lock:
self.pending.pop(request_id, None)
async def _write_json(self, conn: websockets.WebSocketClientProtocol, payload: Any) -> None:
"""线程安全地写入 JSON"""
if not conn:
raise LongConnError("WebSocket connection is None")
async with self.write_lock:
try:
await asyncio.wait_for(conn.send(json.dumps(payload)), timeout=self.write_timeout)
except asyncio.TimeoutError:
raise LongConnError("Write timeout")
def _complete_pending(self, request_id: str, frame: LongConnFrame) -> bool:
"""完成等待中的请求"""
if not request_id:
return False
with self.pending_lock:
future = self.pending.pop(request_id, None)
if future and not future.done():
future.set_result(frame)
return True
return False
def _fail_all_pending(self, error: Exception) -> None:
"""失败所有等待中的请求"""
with self.pending_lock:
pending = self.pending.copy()
self.pending.clear()
for request_id, future in pending.items():
if not future.done():
try:
future.set_exception(error)
except Exception as e:
logger.debug(f"[LongConnBot] Failed to set exception for request {request_id}: {e}")
def _set_conn(self, conn: websockets.WebSocketClientProtocol) -> None:
"""设置当前连接"""
with self.conn_lock:
self.conn = conn
def _release_conn(self, conn: websockets.WebSocketClientProtocol, error: Exception) -> None:
"""释放当前连接"""
with self.conn_lock:
if self.conn == conn:
self.conn = None
if conn:
asyncio.create_task(conn.close())
if error:
self._fail_all_pending(error)
def _current_conn(self) -> Optional[websockets.WebSocketClientProtocol]:
"""获取当前连接"""
with self.conn_lock:
return self.conn
async def send_markdown(self, chat_id: str, content: str) -> None:
"""主动发送 Markdown 消息"""
if not chat_id:
raise ValueError("chat_id is required")
request_id = self.generate_request_id()
body = {
"chatid": chat_id,
"msgtype": "markdown",
"markdown": {"content": content}
}
try:
await self._send_request_and_wait(LongConnCmd.SEND_MSG, request_id, body)
logger.debug(f"[LongConnBot] → Sent markdown to {chat_id}: {content[:50]}...")
except Exception as e:
logger.error(f"[LongConnBot] Failed to send markdown: {e}")
raise
async def send_text(self, chat_id: str, content: str) -> None:
"""主动发送文本消息"""
if not chat_id:
raise ValueError("chat_id is required")
request_id = self.generate_request_id()
body = {
"chatid": chat_id,
"msgtype": "text",
"text": {"content": content}
}
try:
await self._send_request_and_wait(LongConnCmd.SEND_MSG, request_id, body)
logger.debug(f"[LongConnBot] → Sent text to {chat_id}: {content[:50]}...")
except Exception as e:
logger.error(f"[LongConnBot] Failed to send text: {e}")
raise
async def close(self) -> None:
"""关闭长连接机器人"""
with self.close_once:
if self.closed_event.is_set():
logger.debug("[LongConnBot] Already closed")
return
self.closed_event.set()
logger.debug("[LongConnBot] Closing bot...")
self.running = False
# 先清理待处理的请求,避免在关闭时继续发送
self._fail_all_pending(LongConnError("Bot closed"))
# 关闭 WebSocket 连接
conn = self._current_conn()
if conn:
try:
# 使用短超时,避免阻塞
close_task = asyncio.create_task(conn.close())
await asyncio.wait_for(close_task, timeout=1.0)
logger.debug("[LongConnBot] WebSocket closed gracefully")
except asyncio.TimeoutError:
logger.debug("[LongConnBot] WebSocket close timeout, forcing close")
# 强制关闭
try:
if hasattr(conn, 'transport') and conn.transport:
conn.transport.close()
except Exception:
pass
except Exception as e:
logger.debug(f"[LongConnBot] Error closing WebSocket: {e}")
logger.info(f"[LongConnBot] Bot closed")
async def _cleanup(self) -> None:
"""清理资源"""
conn = self._current_conn()
if conn:
self._release_conn(conn, LongConnError("cleanup"))
class WeComChannel(BaseChannel):
"""企业微信频道"""
name = "wecom"
def __init__(self, config: Any):
super().__init__(config)
self.bot_id = getattr(config, "bot_id", "")
self.secret = getattr(config, "secret", "")
self.enabled = getattr(config, "enabled", True)
self.websocket_url = getattr(config, "websocket_url", "wss://openws.work.weixin.qq.com")
self.bot: Optional[LongConnBot] = None
self.start_task: Optional[asyncio.Task] = None
self.stop_event = asyncio.Event()
self._http: Optional[httpx.AsyncClient] = None
async def start(self) -> None:
"""启动频道"""
if not WEBSOCKETS_AVAILABLE:
logger.error("websockets library not installed. Run: pip install websockets")
return
if not self.bot_id or not self.secret:
logger.error("[WeCom] Missing bot_id or secret")
return
if not self.enabled:
logger.info("[WeCom] Channel disabled")
return
self._running = True
self._http = httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=15.0))
logger.info(f"[WeCom] Starting channel...")
# 创建长连接机器人
self.bot = LongConnBot(
bot_id=self.bot_id,
secret=self.secret,
handler=self._handle_message_wrapper,
websocket_url=self.websocket_url
)
# 启动机器人
self.start_task = asyncio.create_task(self.bot.start(self.stop_event))
try:
await self.start_task
except asyncio.CancelledError:
self._running = False
pass
except LongConnPermanentError as e:
self._running = False
logger.error(f"[WeCom] Start error: {e}")
raise
except Exception as e:
self._running = False
logger.error(f"[WeCom] Start error: {e}")
async def _handle_message_wrapper(self, sender_id: str, chat_id: str, content: str, metadata: Dict[str, Any], stream_handler=None) -> Optional[str]:
"""处理收到的消息 - 支持流式回复"""
try:
media_files = await self._resolve_inbound_media(metadata)
effective_content = format_inbound_media_text(media_files, content) if media_files else content
await self._handle_message(
sender_id=sender_id,
chat_id=chat_id,
content=effective_content,
media=media_files or None,
metadata=metadata
)
return None
except Exception as e:
logger.error(f"[WeCom] Message handler error: {e}")
if stream_handler:
await stream_handler("处理消息时发生错误,请稍后重试。", is_final=True)
return None
async def _maybe_send_via_active_stream(
self,
msg: OutboundMessage,
text: str,
) -> bool:
if not self.bot:
return False
metadata = msg.metadata or {}
message_id = str(metadata.get("_wecom_stream_message_id") or "").strip()
frame_headers = metadata.get("_wecom_frame_headers") or {}
req_id = str(frame_headers.get("req_id") or "").strip()
if not message_id or not req_id:
return False
state = self.bot.get_stream_state(message_id)
if not state or state.finished:
return False
content_to_send, msg_items = self.bot.prepare_reply_payload(text, metadata)
if not content_to_send and not msg_items:
return False
await self.bot.send_stream_reply(
LongConnFrame(headers=dict(frame_headers)),
state.stream_id,
content_to_send,
finish=True,
msg_items=msg_items or None,
)
state.finished = True
self.bot.delete_stream_state(message_id)
logger.debug(f"[WeCom] Reused passive stream for outbound reply: {message_id}")
return True
async def send(self, msg: OutboundMessage) -> None:
"""发送消息"""
if not self.bot:
logger.warning("[WeCom] Bot not initialized")
return
try:
text = str(msg.content or "").strip()
if msg.media:
raise ValueError(
"WeCom 长连接模式不支持主动发送附件。"
"当前仅支持接收入站附件,以及在本轮被动回复中附带图片。"
)
if text:
reused_stream = await self._maybe_send_via_active_stream(msg, text)
if not reused_stream:
await self.bot.send_markdown(msg.chat_id, text)
logger.debug(f"[WeCom] → Sent to {msg.chat_id}")
except Exception as e:
logger.error(f"[WeCom] Failed to send message: {e}")
async def stop(self) -> None:
"""停止频道"""
if not self._running:
logger.debug("[WeCom] Channel already stopped")
return
logger.info("[WeCom] Stopping channel...")
self._running = False
self.stop_event.set()
# 先关闭 bot
if self.bot:
try:
await asyncio.wait_for(self.bot.close(), timeout=2.0)
except asyncio.TimeoutError:
logger.warning("[WeCom] Bot close timeout")
except Exception as e:
logger.debug(f"[WeCom] Error closing bot: {e}")
# 再取消启动任务
if self.start_task and not self.start_task.done():
self.start_task.cancel()
try:
await asyncio.wait_for(self.start_task, timeout=1.0)
except asyncio.TimeoutError:
logger.debug("[WeCom] Start task cancel timeout")
except asyncio.CancelledError:
pass
except Exception as e:
logger.debug(f"[WeCom] Error cancelling start task: {e}")
if self._http:
await self._http.aclose()
self._http = None
logger.info("[WeCom] Channel stopped")
async def _resolve_inbound_media(self, metadata: Dict[str, Any]) -> List[str]:
"""解析并下载企业微信入站附件。"""
attachments = self._collect_inbound_attachments(metadata)
if not attachments:
return []
media_files: List[str] = []
for attachment in attachments:
try:
local_path = await self._download_wecom_media(
url=attachment["url"],
aes_key=attachment["aes_key"],
message_id=metadata.get("msgid"),
filename=attachment.get("filename"),
)
if local_path:
media_files.append(local_path)
except Exception as e:
logger.error(f"[WeCom] Failed to download inbound attachment: {e}")
return media_files
@staticmethod
def _collect_inbound_attachments(metadata: Dict[str, Any]) -> List[Dict[str, Optional[str]]]:
"""从企业微信回调体中提取附件信息。"""
attachments: List[Dict[str, Optional[str]]] = []
msg_type = metadata.get("msgtype", "")
if msg_type == "image":
image = metadata.get("image", {}) or {}
if image.get("url") and image.get("aeskey"):
attachments.append(
{
"url": image.get("url"),
"aes_key": image.get("aeskey"),
"filename": image.get("filename"),
}
)
elif msg_type == "file":
file_info = metadata.get("file", {}) or {}
if file_info.get("url") and file_info.get("aeskey"):
attachments.append(
{
"url": file_info.get("url"),
"aes_key": file_info.get("aeskey"),
"filename": file_info.get("filename") or file_info.get("name"),
}
)
elif msg_type == "mixed":
for item in (metadata.get("mixed", {}) or {}).get("msg_item", []) or []:
item_type = item.get("msgtype")
if item_type == "image":
image = item.get("image", {}) or {}
if image.get("url") and image.get("aeskey"):
attachments.append(
{
"url": image.get("url"),
"aes_key": image.get("aeskey"),
"filename": image.get("filename"),
}
)
elif item_type == "file":
file_info = item.get("file", {}) or {}
if file_info.get("url") and file_info.get("aeskey"):
attachments.append(
{
"url": file_info.get("url"),
"aes_key": file_info.get("aeskey"),
"filename": file_info.get("filename") or file_info.get("name"),
}
)
return attachments
async def _download_wecom_media(
self,
*,
url: str,
aes_key: str,
message_id: Optional[str],
filename: Optional[str] = None,
) -> Optional[str]:
"""下载并解密企业微信 bot-ws 附件。"""
if not self._http:
return None
response = await self._http.get(url, follow_redirects=True)
response.raise_for_status()
encrypted = await response.aread()
decrypted = decrypt_wecom_media_bytes(encrypted, aes_key)
resolved_filename = (
filename
or extract_filename_from_content_disposition(response.headers.get("content-disposition"))
)
content_type = response.headers.get("content-type")
local_path = save_bytes_to_temp(
self.name,
decrypted,
message_id=message_id,
filename=resolved_filename,
content_type=content_type,
prefix="wecom_attachment",
)
logger.info(f"[WeCom] Inbound attachment saved: {local_path}")
return local_path
async def test_connection(self) -> Dict[str, Any]:
"""测试连接 - 验证企业微信凭据"""
if not WEBSOCKETS_AVAILABLE:
return {"success": False, "message": "websockets library not installed"}
if not self.bot_id or not self.secret:
return {"success": False, "message": "Bot ID or Secret not configured"}
# 验证 Bot ID 格式
if len(self.bot_id) < 8:
return {"success": False, "message": "Invalid Bot ID format - Bot ID should be at least 8 characters"}
# 验证 Secret 格式
if len(self.secret) < 16:
return {"success": False, "message": "Invalid Secret format - Secret should be at least 16 characters"}
# 验证 Bot ID 格式(企业微信 Bot ID 通常是字母数字组合)
if not all(c.isalnum() or c in '-_' for c in self.bot_id):
return {"success": False, "message": "Invalid Bot ID format - should contain only letters, numbers, hyphens and underscores"}
# 验证 Secret 格式(通常是字母数字组合)
if not all(c.isalnum() or c in '-_' for c in self.secret):
return {"success": False, "message": "Invalid Secret format - should contain only letters, numbers, hyphens and underscores"}
# 验证 WebSocket URL 格式
if not self.websocket_url.startswith(('ws://', 'wss://')):
return {"success": False, "message": "Invalid WebSocket URL format - should start with ws:// or wss://"}
try:
import asyncio
import websockets
import json
import time
# 生成请求ID
request_id = f"test_{int(time.time() * 1000)}"
# 构建订阅命令(企业微信使用 aibot_subscribe 进行认证)
subscribe_message = {
"cmd": "aibot_subscribe",
"headers": {
"req_id": request_id
},
"body": {
"bot_id": self.bot_id,
"secret": self.secret
}
}
# 尝试连接并认证
try:
websocket = await asyncio.wait_for(
websockets.connect(
self.websocket_url,
ping_interval=None, # 测试时禁用自动ping
close_timeout=3
),
timeout=5.0
)
try:
# 发送订阅命令
await asyncio.wait_for(
websocket.send(json.dumps(subscribe_message)),
timeout=3.0
)
# 等待响应
response_text = await asyncio.wait_for(
websocket.recv(),
timeout=5.0
)
response_data = json.loads(response_text)
# 检查认证结果
# 企业微信返回格式: {"errcode": 0, "errmsg": "ok"} 表示成功
err_code = response_data.get("err_code", response_data.get("errcode", -1))
err_msg = response_data.get("err_msg", response_data.get("errmsg", ""))
# 如果响应中没有 err_code,检查是否有其他成功标识
if err_code == -1 and response_data.get("cmd") == "aibot_subscribe":
# 可能是成功响应但没有明确的 err_code
return {
"success": True,
"message": "WeCom credentials verified successfully - connection test passed",
"bot_info": {
"bot_id": self.bot_id[:12] + "...",
"ws_url": self.websocket_url,
"status": "credentials_verified",
"note": "Successfully authenticated with WeCom API"
}
}
if err_code == 0:
return {
"success": True,
"message": "WeCom credentials verified successfully - connection test passed",
"bot_info": {
"bot_id": self.bot_id[:12] + "...",
"ws_url": self.websocket_url,
"status": "credentials_verified",
"note": "Successfully authenticated with WeCom API"
}
}
elif err_code == 40001:
return {
"success": False,
"message": "Invalid Bot ID or Secret - authentication failed (error code: 40001)"
}
elif err_code == 40014:
return {
"success": False,
"message": "Invalid Bot ID or Secret - bot not found or disabled (error code: 40014)"
}
elif err_code == 93019:
return {
"success": False,
"message": "Invalid Bot ID - bot not found or incorrect format (error code: 93019)"
}
else:
return {
"success": False,
"message": f"Invalid Bot ID or Secret - credentials rejected by WeCom: {err_msg} (code: {err_code})"
}
finally:
# 确保关闭 websocket 连接
try:
await websocket.close()
except Exception:
pass
except asyncio.TimeoutError:
return {
"success": False,
"message": "Connection timeout - check your network connection or WeCom API status"
}
except websockets.exceptions.InvalidURI:
return {
"success": False,
"message": "Invalid WebSocket URL - check the websocket_url configuration"
}
except (websockets.exceptions.ConnectionClosed, websockets.exceptions.ConnectionClosedError) as e:
return {
"success": False,
"message": f"Connection closed by server - check your Bot ID and Secret (code: {e.code if hasattr(e, 'code') else 'unknown'})"
}
except json.JSONDecodeError:
return {
"success": False,
"message": "Invalid response format from WeCom server"
}
except ImportError:
return {
"success": False,
"message": "websockets library not installed. Run: pip install websockets"
}
except Exception as e:
logger.error(f"[WeCom] Test connection error: {e}")
return {
"success": False,
"message": f"Network error - unable to reach WeCom API: {str(e)}"
}
@property
def display_name(self) -> str:
return "企业微信"