Files
2026-07-13 13:04:19 +08:00

351 lines
17 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.
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 <user_id>")
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())