import asyncio, os, select, sys, threading, time, traceback from collections import deque from datetime import datetime from typing import Any, Callable, Dict, Optional, TypedDict class TurnContext(TypedDict, total=False): """Hook callback receives agent locals() — these are the keys we rely on.""" exit_reason: Optional[str] response: Any summary: Optional[str] tool_calls: Optional[list] turn: int TurnHookFn = Callable[[TurnContext], None] sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from agentmain import GeneraticAgent from chatapp_common import (AgentChatMixin, FILE_HINT, build_done_text, clean_reply, ensure_single_instance, extract_files, public_access, redirect_log, require_runtime, split_text, strip_files) from llmcore import mykeys try: from wecom_aibot_sdk import WSClient, generate_req_id except Exception: print("Please install wecom_aibot_sdk: pip install wecom_aibot_sdk") sys.exit(1) # ── Config ────────────────────────────────────────────────────────── BOT_ID = str(mykeys.get("wecom_bot_id", "") or "").strip() SECRET = str(mykeys.get("wecom_secret", "") or "").strip() WELCOME = str(mykeys.get("wecom_welcome_message", "") or "").strip() ALLOWED = {str(x).strip() for x in mykeys.get("wecom_allowed_users", []) if str(x).strip()} PORT = 19531 # single-instance lock port TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp") MEDIA_DIR = os.path.join(TEMP_DIR, "media") IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"} # ── Helpers ───────────────────────────────────────────────────────── def _ts(): return datetime.now().strftime("%H:%M:%S") def _tprint(*a, **kw): kw.setdefault("file", sys.__stdout__) print(*a, **kw) if hasattr(sys.__stdout__, "flush"): sys.__stdout__.flush() def _fmt_tool(tc): name = tc.get("tool_name", "?") args = {k: v for k, v in (tc.get("args") or {}).items() if not k.startswith("_")} return f"{name}({str(args)[:120]})" # ── WeComApp ──────────────────────────────────────────────────────── class WeComApp(AgentChatMixin): label, source, split_limit = "WeCom", "wecom", 1200 # split_limit: wecom single-msg char cap def __init__(self, agent): self.agent = agent if not hasattr(agent, '_turn_end_hooks'): agent._turn_end_hooks = {} super().__init__(agent, {}) self._allowed = ALLOWED self.client = None self.chat_frames = {} # chat_id → latest frame (for reply) self._seen = deque(maxlen=1000) self._stats = {"received": 0, "completed": 0} # ── hook management ────────────────────────────────────────────── def _register_hook(self, key: str, fn: TurnHookFn) -> None: """Register a turn-end callback on the agent.""" self.agent._turn_end_hooks[key] = fn def _unregister_hook(self, key: str) -> None: """Remove a turn-end callback.""" self.agent._turn_end_hooks.pop(key, None) # ── frame accept: dedup → auth → register ─────────────────────── def _accept(self, frame): """Parse incoming frame. Returns (body, sender_id, chat_id) or None.""" body = frame.body if hasattr(frame, "body") else frame.get("body", frame) if isinstance(frame, dict) else {} msg_id = body.get("msgid") or f"{body.get('chatid', '')}_{body.get('sendertime', '')}_{id(frame)}" if msg_id in self._seen: return None self._seen.append(msg_id) sender_id = str((body.get("from") or {}).get("userid", "") or "unknown") chat_id = str(body.get("chatid", "") or sender_id) if not public_access(ALLOWED) and sender_id not in ALLOWED: print(f"[WeCom] unauthorized: {sender_id}") return None self.chat_frames[chat_id] = frame self._stats["received"] += 1 return body, sender_id, chat_id async def _save_media(self, url, aes_key, default_name): """Download encrypted media → save to MEDIA_DIR → return local path.""" os.makedirs(MEDIA_DIR, exist_ok=True) result = await self.client.download_file(url, aes_key or None) buf = result["buffer"] fname = result.get("filename") or default_name path = os.path.join(MEDIA_DIR, fname) with open(path, "wb") as f: f.write(buf) _tprint(f"[{_ts()}] 💾 Saved: {path} ({len(buf)} bytes)") return path # ── send ──────────────────────────────────────────────────────── async def send_text(self, chat_id, content, **_): if not self.client or chat_id not in self.chat_frames: return frame = self.chat_frames[chat_id] for part in split_text(content, self.split_limit): await self.client.reply_stream(frame, generate_req_id("stream"), part, finish=True) async def send_media(self, chat_id, file_path): if not self.client or not os.path.isfile(file_path): return ext = os.path.splitext(file_path)[1].lower() media_type = "image" if ext in IMAGE_EXTS else "file" with open(file_path, "rb") as f: data = f.read() try: result = await self.client.upload_media(data, type=media_type, filename=os.path.basename(file_path)) frame = self.chat_frames.get(chat_id) if frame: await self.client.reply_media(frame, media_type, result["media_id"]) else: await self.client.send_media_message(chat_id, media_type, result["media_id"]) _tprint(f"[{_ts()}] 📤 Sent {media_type}: {os.path.basename(file_path)}") except Exception as e: print(f"[WeCom] send_media error: {e}") await self.send_text(chat_id, f"📎 {os.path.basename(file_path)}(发送失败: {e})") async def send_done(self, chat_id, raw_text): """Send final result: text + extracted file attachments.""" files = extract_files(raw_text) if not files: return await self.send_text(chat_id, build_done_text(raw_text)) clean = clean_reply(strip_files(raw_text)) if clean and clean != "...": await self.send_text(chat_id, clean) for fp in files: if not os.path.isabs(fp) and not os.path.isfile(fp): resolved = os.path.join(TEMP_DIR, fp) if os.path.isfile(resolved): fp = resolved await self.send_media(chat_id, fp) # ── agent execution (single-channel via turn hook) ────────────── async def run_agent(self, chat_id, text, **_): state = {"running": True} self.user_tasks[chat_id] = state done_event = threading.Event() result = {} loop = asyncio.get_running_loop() hook_key = f"wecom_{chat_id}" # namespace: wecom_ + chat_id, matches _turn_end_hooks convention def _on_turn(ctx): """Turn-end callback injected into agent. ctx = locals() from ga.py.""" try: if ctx.get("exit_reason"): resp = ctx.get("response") result["raw"] = resp.content if hasattr(resp, "content") else str(resp) result["summary"] = ctx.get("summary") done_event.set() return summary = ctx.get("summary") if not summary: return turn = ctx.get("turn", "?") tools = ctx.get("tool_calls") or [] parts = [f"⏳ Turn {turn}: {summary}"] if tools: parts.append(f"🛠 {', '.join(_fmt_tool(tc) for tc in tools[:3])}") _tprint(f"[{_ts()}] {parts[0]}") asyncio.run_coroutine_threadsafe(self.send_text(chat_id, "\n".join(parts)), loop) except Exception as e: print(f"[WeCom hook] {e}") traceback.print_exc() try: await self.send_text(chat_id, "🤔 思考中...") self._register_hook(hook_key, _on_turn) self.agent.put_task(f"{FILE_HINT}\n\n{text}", source=self.source) # Wait for: hook signals done / user stops / agent crashes t0 = time.time() while state["running"] and not done_event.is_set(): await asyncio.sleep(1) elapsed = time.time() - t0 if elapsed > 10 and not self.agent.is_running: await asyncio.sleep(3) # grace period for hook delivery if not done_event.is_set(): break if result.get("raw") is not None: self._stats["completed"] += 1 await self.send_done(chat_id, result["raw"]) label = result.get("summary") or f'{len(result["raw"])} 字' _tprint(f"[{_ts()}] ✅ Done ({chat_id}) — {label}") elif not state["running"]: _tprint(f"[{_ts()}] ⏹️ 停止 ({chat_id})") await self.send_text(chat_id, "⏹️ 已停止") else: _tprint(f"[{_ts()}] ⚠️ 异常退出 ({chat_id})") await self.send_text(chat_id, "⚠️ Agent 异常退出,请重试") except Exception as e: traceback.print_exc() await self.send_text(chat_id, f"❌ 错误: {e}") finally: self._unregister_hook(hook_key) self.user_tasks.pop(chat_id, None) # ── message handlers ──────────────────────────────────────────── async def on_text(self, frame): parsed = self._accept(frame) if not parsed: return body, sender_id, chat_id = parsed content = str((body.get("text", {}) or {}).get("content", "") or "").strip() if not content: return _tprint(f"[{_ts()}] 📩 {sender_id}: {content}") if content.startswith("/"): _tprint(f"[{_ts()}] 🔧 命令 {content} from {sender_id}") return await self.handle_command(chat_id, content) asyncio.create_task(self.run_agent(chat_id, content)) async def _on_media(self, frame, key, icon): """Common handler for image/file messages.""" parsed = self._accept(frame) if not parsed: return body, sender_id, chat_id = parsed info = body.get(key) or {} url = info.get("url", "") if not url: return fname = info.get("file_name") or info.get("filename") or "" msgid = body.get("msgid", "x")[:16] default = f"img_{msgid}.jpg" if key == "image" else (fname or f"file_{msgid}") try: _tprint(f"[{_ts()}] {icon} {key.title()} from {sender_id}" + (f": {fname}" if fname else "")) path = await self._save_media(url, info.get("aeskey", ""), default) label = "一张图片" if key == "image" else f"文件 {os.path.basename(path)}" asyncio.create_task(self.run_agent(chat_id, f"[用户发送了{label},已保存到: {path}]")) except Exception as e: print(f"[WeCom] on_{key} error: {e}") await self.send_text(chat_id, f"❌ {key}处理失败: {e}") async def on_image(self, frame): await self._on_media(frame, "image", "🖼️") async def on_file(self, frame): await self._on_media(frame, "file", "📎") # ── lifecycle ─────────────────────────────────────────────────── async def on_enter_chat(self, frame): if WELCOME and self.client: try: await self.client.reply_welcome(frame, {"msgtype": "text", "text": {"content": WELCOME}}) except Exception as e: print(f"[WeCom] welcome error: {e}") async def on_connected(self, *_): _tprint("[WeCom] connected") async def on_authenticated(self, *_): _tprint("[WeCom] authenticated, 等待消息中...\n") async def on_disconnected(self, *_): _tprint("[WeCom] disconnected") async def on_error(self, frame): _tprint(f"[WeCom] error: {frame}") # ── Terminal CLI (runs in background thread) ───────────────────── def _terminal_loop(self): """Blocking CLI loop — run in a daemon thread.""" while True: try: if not select.select([sys.stdin], [], [], 1.0)[0]: continue cmd = sys.stdin.readline().strip().lower() except Exception: break if not cmd: continue if cmd == "help": _tprint(" status — 查看状态") _tprint(" stop [user] — 停止任务(多任务时需指定 user)") _tprint(" exit — 退出进程") elif cmd == "status": _tprint(f"[{_ts()}] 📊 收到 {self._stats['received']} 条 | 完成 {self._stats['completed']} 条 | 活跃 {len(self.user_tasks)}") for uid, st in self.user_tasks.items(): _tprint(f" ├ {uid}: running={st.get('running')}") _tprint(f" Agent running: {self.agent.is_running} | 允许: {self._allowed or '全部'}") elif cmd.startswith("stop"): parts = cmd.split(None, 1) tasks = self.user_tasks if not tasks: _tprint(" 没有活跃任务") elif len(parts) > 1: uid = parts[1] if uid in tasks: tasks[uid]["running"] = False _tprint(f" ⏹️ 已停止 {uid}") else: _tprint(f" 未找到: {uid}") elif len(tasks) == 1: uid = next(iter(tasks)) tasks[uid]["running"] = False _tprint(f" ⏹️ 已停止 {uid}") else: _tprint(" 多个任务,请指定: stop ") for uid in tasks: _tprint(f" ├ {uid}") elif cmd == "exit": _tprint(f"[{_ts()}] 👋 退出...") os._exit(0) else: _tprint(" 可用命令: help | status | stop | exit") async def start(self, client=None): self.client = client or WSClient(BOT_ID, SECRET, reconnect_interval=1000, max_reconnect_attempts=-1, heartbeat_interval=30000) for ev, fn in { "connected": self.on_connected, "authenticated": self.on_authenticated, "disconnected": self.on_disconnected, "error": self.on_error, "message.text": self.on_text, "message.image": self.on_image, "message.file": self.on_file, "event.enter_chat": self.on_enter_chat, }.items(): self.client.on(ev, fn) _tprint("[WeCom] starting ...") await self.client.connect() while True: await asyncio.sleep(1) # ── Main ──────────────────────────────────────────────────────────── if __name__ == "__main__": agent = GeneraticAgent(); agent.verbose = False _LOCK = ensure_single_instance(PORT, "WeCom") require_runtime(agent, "WeCom", wecom_bot_id=BOT_ID, wecom_secret=SECRET) redirect_log(__file__, "wecomapp.log", "WeCom", ALLOWED) _tprint("\n═══════════════════════════════════════════") _tprint(" 企业微信 Agent (长连接模式)") _tprint(f" 端口锁: {PORT} | 允许用户: {ALLOWED or '全部'}") _tprint("═══════════════════════════════════════════") _tprint(" 终端命令: help | status | stop | exit") app = WeComApp(agent) threading.Thread(target=agent.run, daemon=True).start() threading.Thread(target=app._terminal_loop, daemon=True).start() asyncio.run(app.start())